mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-09 17:57:16 +00:00
Part B of #2128: a logged-in user's settings follow them across devices. Log in on a phone and your own nodes, favorites, customizer and filters are there; a change on one device reaches the others within a minute or when you return to the tab. **This PR builds on #2129.** Until that one is merged, the diff here includes it. The commits for this part start at `docs(specs): settings sync for optional user management (sub-project B)`. ## The situation Everything a visitor sets up lives in one browser's `localStorage` (about 100 keys in `public/`). A second device or a cleared cache starts from zero (#895). ## What this PR adds **Storage.** `users.db` schema v2: one JSON document per user in `user_settings`, with a revision number and a generation id. A write succeeds only when the client's revision and generation match the stored ones, so two devices cannot overwrite each other silently. **Server.** `GET`, `PUT` and `DELETE /api/account/settings`, behind the same session and CSRF checks as the account routes. - The server owns the list of synced keys (61 keys, [`settings_allowlist.go`](https://github.com/efiten/CoreScope/blob/feat/settings-sync/cmd/server/settings_allowlist.go)) and sends it to the client, so the two cannot drift. - A hard denylist, checked first, refuses `meshcore-api-key`, every `corescope_channel_*` key and `live-channel-colors` (#725). The colour map is keyed by channel hash, and for a user-added channel that hash is `user:<name>`, which would expose hashtag channel names. - Documents are capped at 256 KiB, measured like `JSON.stringify`. PUT is limited to 60 requests per hour per user. A stale revision gets 409 with the current document. **Client** ([`settings-sync.js`](https://github.com/efiten/CoreScope/blob/feat/settings-sync/public/settings-sync.js)). Inert unless the feature is on and someone is logged in. - It wraps `localStorage.setItem` and `removeItem` for allowlisted keys only and pushes 2 seconds after the last change. - It pulls on login, page load, tab focus and every 60 seconds while the tab is visible. - **Merge:** three-way, against a per-device baseline that belongs to one user and one document generation. Lists (own nodes, favorites, saved filters) merge per item, so an item added anywhere is kept and an item removed on one device does not come back from another. Single values: the profile wins unless only this device changed it. - Remote changes are written without a push, theme and colour-blind preset are re-applied, and the current page re-renders (skipped on account pages and while the geofilter editor is open). **UI.** - Logout asks: keep my settings on this device (default), remove them from this device, or cancel. Channel keys are never removed: no copy exists anywhere else. - The account page gets a "Settings sync" section: last synced time, "Sync now", what is and is not synced, and "Delete synced settings from my account". ## Not synced Layout and device state (panel and column widths, collapsed panels, map positions, geofilter drafts), channel data (#725), the API key, and all `sessionStorage`. The full list is in the [spec](https://github.com/efiten/CoreScope/blob/feat/settings-sync/docs/specs/2026-10-06-user-settings-sync-design.md). ## Performance - One GET per page load, tab focus and minute while visible; one debounced PUT per burst of changes. - The `setItem` wrapper costs one Set lookup per write for non-synced keys. A synced write reads one small revision key, not the stored document. - The server reads or writes one row per request. ## Verification - `internal/users` and `cmd/server`: `go vet` and `go test` pass locally (22 new Go tests), including a test that every allowlisted key still occurs in `public/`, and denylist tests. - `tests/unit/test-settings-sync.js`: 79 passing (vm, real module). The cases cover the merge table, two tabs sharing one storage, stale answers after a push, delete while a push is in flight, and logout while the final push fails. - `sh test-all.sh` exits 0. - `tests/e2e/test-user-management-e2e.js` (10 steps, 4 of them new) passed locally with two browser contexts as two devices: a favorite and the packet time window travel from device 1 to device 2, a removal does not come back, and "remove from this device" clears the synced keys while a channel key stays. - Checked by hand on a staging instance with a desktop and a phone on one account. ## Not in this PR - On a shared browser where the previous user chose "keep", the next user's first login merges those settings into their own account. The user guide says to choose "remove" on shared computers. - Saved filter expressions are synced as typed, including any channel names written in them. The guide says so. - Realtime push between devices; the minute pull is the sync interval. --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1195 lines
58 KiB
JavaScript
1195 lines
58 KiB
JavaScript
/* Unit tests for public/settings-sync.js (settings sync, sub-project B).
|
|
* The real file runs in a vm sandbox with a fake Storage (a real
|
|
* prototype, so the module's Storage.prototype wrap is exercised), a fake
|
|
* CSAuth backed by a fake server, and fake timers. Tests run one by one. */
|
|
/* global setImmediate */
|
|
'use strict';
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const assert = require('assert');
|
|
|
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
const SRC = fs.readFileSync(path.join(ROOT, 'public/settings-sync.js'), 'utf8');
|
|
const J = JSON.stringify;
|
|
// Objects made inside the vm have another Object.prototype; compare plain copies.
|
|
const plain = (x) => JSON.parse(JSON.stringify(x));
|
|
const settle = () => new Promise((r) => setImmediate(r));
|
|
|
|
const tests = [];
|
|
function test(name, fn) { tests.push({ name, fn }); }
|
|
|
|
// escapeHtml comes from the real app.js, not a copy.
|
|
function loadEscapeHtml() {
|
|
const src = fs.readFileSync(path.join(ROOT, 'public/app.js'), 'utf8');
|
|
const m = src.match(/function escapeHtml\(s\) \{[\s\S]*?\n\}/);
|
|
assert(m, 'escapeHtml not found in app.js');
|
|
return vm.runInNewContext('(' + m[0].replace(/^function escapeHtml/, 'function') + ')');
|
|
}
|
|
|
|
// CSAuth.say and CSAuth.errText come from the real auth.js, bound to ctx
|
|
// (say reads ctx.document).
|
|
function loadAuthHelpers(ctx) {
|
|
const src = fs.readFileSync(path.join(ROOT, 'public/auth.js'), 'utf8');
|
|
const say = src.match(/function say\(id, text, ok\) \{[\s\S]*?\n \}/);
|
|
const err = src.match(/function errText\(r\) \{.*\}/);
|
|
assert(say && err, 'say/errText not found in auth.js');
|
|
return { say: vm.runInContext('(' + say[0] + ')', ctx), errText: vm.runInContext('(' + err[0] + ')', ctx) };
|
|
}
|
|
|
|
function fakeTimers() {
|
|
let now = 0, seq = 0;
|
|
const list = [];
|
|
const api = {
|
|
setTimeout(fn, ms) { list.push({ id: ++seq, at: now + (ms || 0), fn }); return seq; },
|
|
clearTimeout(id) { const i = list.findIndex((t) => t.id === id); if (i >= 0) list.splice(i, 1); },
|
|
setInterval(fn, ms) { list.push({ id: ++seq, at: now + ms, fn, every: ms }); return seq; },
|
|
clearInterval(id) { api.clearTimeout(id); },
|
|
async advance(ms) {
|
|
const end = now + ms;
|
|
for (;;) {
|
|
await settle();
|
|
list.sort((a, b) => a.at - b.at);
|
|
const t = list[0];
|
|
if (!t || t.at > end) break;
|
|
now = t.at;
|
|
if (t.every) t.at += t.every; else list.shift();
|
|
t.fn();
|
|
}
|
|
now = end;
|
|
await settle();
|
|
},
|
|
// Pending one-shot timers, as delays from now.
|
|
delays() { return list.filter((t) => !t.every).map((t) => t.at - now).sort((a, b) => a - b); },
|
|
};
|
|
return api;
|
|
}
|
|
|
|
const ALLOW = [
|
|
{ key: 'meshcore-favorites', kind: 'set' },
|
|
{ key: 'meshcore-my-nodes', kind: 'set', id: 'pubkey' },
|
|
{ key: 'meshcore-time-window', kind: 'scalar' },
|
|
{ key: 'meshcore-theme', kind: 'scalar' },
|
|
{ key: 'meshcore-cb-preset', kind: 'scalar' },
|
|
{ key: 'cs-theme-overrides', kind: 'scalar' },
|
|
{ key: 'mc-dark-tile-provider', kind: 'scalar' },
|
|
{ key: 'mc-light-tile-provider', kind: 'scalar' },
|
|
];
|
|
|
|
// fakeServer keeps one account document with the semantics of
|
|
// cmd/server/settings_handlers.go and internal/users/settings.go: the first
|
|
// write starts a new generation, later writes must name it. fail[METHOD]
|
|
// queues canned answers ('network' rejects the request).
|
|
function fakeServer() {
|
|
const s = { rev: 0, gen: '', gens: 0, doc: null, puts: [], gets: 0, deletes: 0, fail: { GET: [], PUT: [], DELETE: [] } };
|
|
s.handle = (method, p, body) => {
|
|
if (method === 'PUT') s.puts.push(body);
|
|
if (method === 'GET') s.gets++;
|
|
if (s.fail[method].length) return s.fail[method].shift();
|
|
if (method === 'GET') return { status: 200, data: { revision: s.rev, generation: s.gen, doc: s.doc, allowlist: ALLOW } };
|
|
if (method === 'PUT') {
|
|
if (body.baseRevision !== s.rev || (s.rev && body.baseGeneration !== s.gen)) {
|
|
return { status: 409, data: { revision: s.rev, generation: s.gen, doc: s.doc } };
|
|
}
|
|
if (!s.rev) s.gen = 'gen' + (++s.gens);
|
|
s.rev++;
|
|
s.doc = body.doc;
|
|
return { status: 200, data: { revision: s.rev, generation: s.gen } };
|
|
}
|
|
s.deletes++;
|
|
s.rev = 0;
|
|
s.gen = '';
|
|
s.doc = null;
|
|
return { status: 200, data: { ok: true } };
|
|
};
|
|
return s;
|
|
}
|
|
|
|
const AUTO_IDS = ['syncStatus', 'syncNow', 'syncDelete', 'syncMsg'];
|
|
|
|
// fakeOverlay is the element showDialog creates. Its buttons are read back
|
|
// from the rendered HTML; focus() moves doc.activeElement.
|
|
function fakeOverlay(doc) {
|
|
const node = { className: '', innerHTML: '', handlers: {}, removed: false, buttons: null };
|
|
node.addEventListener = (t, f) => { node.handlers[t] = f; };
|
|
node.remove = () => { node.removed = true; };
|
|
node.closest = () => null;
|
|
node.querySelectorAll = () => {
|
|
if (!node.buttons) {
|
|
node.buttons = [...node.innerHTML.matchAll(/data-choice="([^"]*)"/g)].map((m) => {
|
|
const b = { getAttribute: () => m[1], focus() { doc.activeElement = b; } };
|
|
b.closest = () => b;
|
|
return b;
|
|
});
|
|
}
|
|
return node.buttons;
|
|
};
|
|
return node;
|
|
}
|
|
|
|
// makeEnv loads the real module. opts: local {key: raw}, server,
|
|
// user (default {id: 7}; null = logged out), enabled (default true), hash,
|
|
// customizerReady (default true: the customizer finished its init),
|
|
// shared (a Map: localStorage contents shared with another env, as two tabs
|
|
// of one browser share them; each env keeps its own Storage prototype),
|
|
// prefersDark (the OS colour scheme matchMedia reports).
|
|
function makeEnv(opts) {
|
|
opts = opts || {};
|
|
function Storage() { this.m = opts.shared || new Map(); }
|
|
Storage.prototype.getItem = function (k) { return this.m.has(k) ? this.m.get(k) : null; };
|
|
Storage.prototype.setItem = function (k, v) { this.m.set(k, String(v)); };
|
|
Storage.prototype.removeItem = function (k) { this.m.delete(k); };
|
|
const originalSet = Storage.prototype.setItem;
|
|
const ls = new Storage();
|
|
Object.keys(opts.local || {}).forEach((k) => ls.m.set(k, opts.local[k]));
|
|
const server = opts.server || fakeServer();
|
|
const timers = fakeTimers();
|
|
const enabled = opts.enabled !== false;
|
|
let user = opts.user === undefined ? { id: 7 } : opts.user;
|
|
let logoutHandler = null;
|
|
const winListeners = {}, docListeners = {}, els = {};
|
|
const toasts = [], events = [], warnings = [], errors = [];
|
|
const mkEl = (id) => {
|
|
const el = { id, innerHTML: '', textContent: '', handlers: {}, cls: {} };
|
|
el.classList = { toggle(c, on) { el.cls[c] = !!on; } };
|
|
el.addEventListener = (t, fn) => { el.handlers[t] = fn; };
|
|
return el;
|
|
};
|
|
const auth = {
|
|
isEnabled: () => enabled,
|
|
user: () => user,
|
|
ready: () => Promise.resolve(enabled ? user : null),
|
|
request(method, p, body) {
|
|
const r = server.handle(method, p, body === undefined ? undefined : JSON.parse(JSON.stringify(body)));
|
|
if (r === 'network') return Promise.reject(new Error('offline'));
|
|
return Promise.resolve({ ok: r.status < 400, status: r.status, data: r.data || {} });
|
|
},
|
|
notify(m) { toasts.push(m); },
|
|
setLogoutHandler(fn) { logoutHandler = fn; },
|
|
// env.nextMe: the user /api/auth/me reports now (undefined: unchanged).
|
|
refreshMe() {
|
|
env.refreshes++;
|
|
if (env.nextMe !== undefined) { user = env.nextMe; (winListeners['cs-auth-changed'] || []).forEach((f) => f({ detail: user })); }
|
|
return Promise.resolve(user);
|
|
},
|
|
};
|
|
const env = { navigations: 0, pipelines: 0, resets: 0, presetClears: 0, refreshes: 0, nextMe: undefined, tileSets: [] };
|
|
// map-tile-providers.js: the getter falls back to the default when the key
|
|
// is unset, the setter persists through localStorage (the wrapped setItem).
|
|
const tileApi = (type, key, def) => ({
|
|
get: () => ls.getItem(key) || def,
|
|
set: (id) => { env.tileSets.push([type, id]); win.localStorage.setItem(key, id); return true; },
|
|
});
|
|
const darkTiles = tileApi('dark', 'mc-dark-tile-provider', 'carto-dark');
|
|
const lightTiles = tileApi('light', 'mc-light-tile-provider', 'carto-light');
|
|
const win = {
|
|
localStorage: ls, Storage, CSAuth: auth, MC_USER_MGMT: enabled ? { enabled: true } : null,
|
|
addEventListener(t, f) { (winListeners[t] = winListeners[t] || []).push(f); },
|
|
dispatchEvent(e) { events.push(e); },
|
|
navigate() { env.navigations++; },
|
|
_customizerV2: { initDone: opts.customizerReady !== false, runPipeline() { env.pipelines++; }, resetAll() { env.resets++; } },
|
|
MeshCorePresets: { clearPreset() { env.presetClears++; } },
|
|
MC_getDarkTileProvider: darkTiles.get, MC_setDarkTileProvider: darkTiles.set,
|
|
MC_getLightTileProvider: lightTiles.get, MC_setLightTileProvider: lightTiles.set,
|
|
matchMedia: (q) => ({ matches: q === '(prefers-color-scheme: dark)' && !!opts.prefersDark }),
|
|
};
|
|
const doc = {
|
|
visibilityState: 'visible',
|
|
activeElement: null,
|
|
body: { children: [], appendChild(n) { this.children.push(n); } },
|
|
createElement() { return fakeOverlay(doc); },
|
|
addEventListener(t, f) { docListeners[t] = f; },
|
|
removeEventListener(t, f) { if (docListeners[t] === f) delete docListeners[t]; },
|
|
getElementById(id) { return els[id] || (AUTO_IDS.indexOf(id) !== -1 ? (els[id] = mkEl(id)) : null); },
|
|
};
|
|
const ctx = {
|
|
window: win, document: doc, location: { hash: opts.hash || '#/packets' },
|
|
console: { warn: (m) => warnings.push(String(m)), error: (m) => errors.push(String(m)), log() {} },
|
|
Promise,
|
|
StorageEvent: function (type, init) { this.type = type; this.key = init.key; this.newValue = init.newValue; },
|
|
escapeHtml: loadEscapeHtml(),
|
|
setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout,
|
|
setInterval: timers.setInterval, clearInterval: timers.clearInterval,
|
|
};
|
|
vm.createContext(ctx);
|
|
Object.assign(auth, loadAuthHelpers(ctx));
|
|
vm.runInContext(SRC, ctx);
|
|
Object.defineProperty(env, 'logoutHandler', { get: () => logoutHandler });
|
|
Object.assign(env, {
|
|
ls, server, timers, toasts, events, warnings, errors, els, doc, ctx, win, originalSet,
|
|
api: win.CSSettingsSync, t: win.CSSettingsSync._test,
|
|
login(u) { user = u; (winListeners['cs-auth-changed'] || []).forEach((f) => f({ detail: u })); return settle(); },
|
|
logout() { user = null; (winListeners['cs-auth-changed'] || []).forEach((f) => f({ detail: null })); return settle(); },
|
|
fireDoc(t) { if (docListeners[t]) docListeners[t](); return settle(); },
|
|
el(id) { return els[id] || (els[id] = mkEl(id)); },
|
|
});
|
|
return env;
|
|
}
|
|
|
|
// ── mergeDocs ──
|
|
const M_ALLOW = [{ key: 'fav', kind: 'set' }, { key: 'nodes', kind: 'set', id: 'pubkey' }, { key: 'tw', kind: 'scalar' }];
|
|
const n = (pubkey, name) => ({ pubkey, name });
|
|
const MERGE_CASES = [
|
|
// name, local, profile, base, keys, localChanges, differsFromProfile
|
|
['set: an item added here since the last sync is kept',
|
|
{ fav: J(['a', 'b']) }, { fav: J(['a']) }, { fav: J(['a']) }, { fav: J(['a', 'b']) }, [], true],
|
|
['set: an item removed here since the last sync is removed',
|
|
{ fav: J(['a']) }, { fav: J(['a', 'b']) }, { fav: J(['a', 'b']) }, { fav: J(['a']) }, [], true],
|
|
['set: an item added on another device arrives',
|
|
{ fav: J(['a']) }, { fav: J(['a', 'c']) }, { fav: J(['a']) }, { fav: J(['a', 'c']) }, ['fav'], false],
|
|
['set: an item removed on another device is not brought back',
|
|
{ fav: J(['a', 'b']) }, { fav: J(['a']) }, { fav: J(['a', 'b']) }, { fav: J(['a']) }, ['fav'], false],
|
|
['set: additions on both sides are both kept',
|
|
{ fav: J(['a', 'x']) }, { fav: J(['a', 'y']) }, { fav: J(['a']) }, { fav: J(['a', 'y', 'x']) }, ['fav'], true],
|
|
['set: without a baseline the lists merge by union',
|
|
{ fav: J(['x']) }, { fav: J(['y']) }, {}, { fav: J(['y', 'x']) }, ['fav'], true],
|
|
['set: same identity edited only here keeps the local version',
|
|
{ nodes: J([n('k1', 'new')]) }, { nodes: J([n('k1', 'old')]) }, { nodes: J([n('k1', 'old')]) }, { nodes: J([n('k1', 'new')]) }, [], true],
|
|
['set: same identity edited on both sides keeps the profile version',
|
|
{ nodes: J([n('k1', 'new')]) }, { nodes: J([n('k1', 'other')]) }, { nodes: J([n('k1', 'old')]) }, { nodes: J([n('k1', 'other')]) }, ['nodes'], false],
|
|
['set: same identity edited only in the profile arrives',
|
|
{ nodes: J([n('k1', 'old')]) }, { nodes: J([n('k1', 'other')]) }, { nodes: J([n('k1', 'old')]) }, { nodes: J([n('k1', 'other')]) }, ['nodes'], false],
|
|
['scalar: changed only here wins',
|
|
{ tw: '60' }, { tw: '15' }, { tw: '15' }, { tw: '60' }, [], true],
|
|
['scalar: changed in the profile wins',
|
|
{ tw: '15' }, { tw: '180' }, { tw: '15' }, { tw: '180' }, ['tw'], false],
|
|
['scalar: changed on both sides, the profile wins',
|
|
{ tw: '60' }, { tw: '180' }, { tw: '15' }, { tw: '180' }, ['tw'], false],
|
|
['scalar: removed here wins',
|
|
{}, { tw: '15' }, { tw: '15' }, {}, [], true],
|
|
['scalar: removed in the profile is removed here',
|
|
{ tw: '15' }, {}, { tw: '15' }, {}, ['tw'], false],
|
|
['set: the same list spelled differently takes the profile spelling and does not differ from it',
|
|
{ fav: '["a", "b"]' }, { fav: '["a","b"]' }, { fav: '["a","b"]' }, { fav: '["a","b"]' }, ['fav'], false],
|
|
['set: an unparseable baseline counts as none, so lists merge by union',
|
|
{ fav: J(['a', 'x']) }, { fav: J(['a', 'y']) }, { fav: 'garbage' }, { fav: J(['a', 'y', 'x']) }, ['fav'], true],
|
|
['set: an item edited here but deleted on another device stays deleted',
|
|
{ nodes: J([n('k1', 'new')]) }, { nodes: J([]) }, { nodes: J([n('k1', 'old')]) }, { nodes: J([]) }, ['nodes'], false],
|
|
['set: duplicate items on this device dedupe by identity',
|
|
{ fav: J(['a', 'b', 'a']) }, { fav: J(['a']) }, { fav: J(['a']) }, { fav: J(['a', 'b']) }, ['fav'], true],
|
|
['set: objects without the id field fall back to JSON identity',
|
|
{ nodes: J([{ name: 'x' }]) }, { nodes: J([{ name: 'y' }]) }, { nodes: J([]) }, { nodes: J([{ name: 'y' }, { name: 'x' }]) }, ['nodes'], true],
|
|
['keys outside the allowlist are ignored',
|
|
{ other: '1', tw: '15' }, { other: '2', tw: '15' }, {}, { tw: '15' }, [], false],
|
|
];
|
|
|
|
MERGE_CASES.forEach(([name, l, p, b, keys, changes, differs]) => {
|
|
test('merge ' + name, () => {
|
|
const env = makeEnv({ enabled: false });
|
|
const r = plain(env.t.mergeDocs(l, p, b, M_ALLOW));
|
|
assert.deepStrictEqual(r.keys, keys);
|
|
assert.deepStrictEqual(r.localChanges, changes);
|
|
assert.strictEqual(r.differsFromProfile, differs);
|
|
});
|
|
});
|
|
|
|
test('merge: a set value that is not a JSON list merges as a scalar, with a warning', () => {
|
|
const env = makeEnv({ enabled: false });
|
|
const r = plain(env.t.mergeDocs({ fav: 'not json' }, { fav: J(['a']) }, { fav: J(['a']) }, M_ALLOW));
|
|
assert.deepStrictEqual(r.keys, { fav: 'not json' });
|
|
assert.strictEqual(env.warnings.length, 1);
|
|
assert(env.warnings[0].indexOf('fav') !== -1, env.warnings[0]);
|
|
});
|
|
|
|
// ── engine ──
|
|
// Tests that do not name a generation use 'g0' on both sides: the device
|
|
// synced the account's current document.
|
|
const BASE = (user, keys, rev, hold, gen) => ({ 'cs-settings-sync-base': J({ user, keys, hold: !!hold, gen: gen === undefined ? (rev ? 'g0' : '') : gen }), 'cs-settings-sync-rev': String(rev) });
|
|
const synced = (keys, rev, gen) => Object.assign({}, keys, BASE(7, keys, rev, false, gen));
|
|
const serverWith = (rev, keys, gen) => { const s = fakeServer(); s.rev = rev; s.gen = rev ? (gen || 'g0') : ''; s.doc = rev ? { v: 1, keys } : null; return s; };
|
|
|
|
test('feature off: no request and no interception', async () => {
|
|
const env = makeEnv({ enabled: false });
|
|
await env.timers.advance(120000);
|
|
assert.strictEqual(env.server.gets + env.server.puts.length, 0);
|
|
assert.strictEqual(env.win.Storage.prototype.setItem, env.originalSet);
|
|
});
|
|
|
|
test('logged out: no request and no interception', async () => {
|
|
const env = makeEnv({ user: null });
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
await env.timers.advance(120000);
|
|
assert.strictEqual(env.server.gets + env.server.puts.length, 0);
|
|
assert.strictEqual(env.win.Storage.prototype.setItem, env.originalSet);
|
|
});
|
|
|
|
test('first login with an empty profile uploads this device (never channel keys)', async () => {
|
|
const env = makeEnv({ user: null, local: { 'meshcore-favorites': J(['a']), 'meshcore-time-window': '60', corescope_channel_keys: '{"#x":"00"}', 'meshcore-api-key': 'k' } });
|
|
await env.login({ id: 7 });
|
|
assert.strictEqual(env.server.puts.length, 1);
|
|
assert.deepStrictEqual(env.server.puts[0], { baseRevision: 0, baseGeneration: '', doc: { v: 1, keys: { 'meshcore-favorites': J(['a']), 'meshcore-time-window': '60' } } });
|
|
assert.strictEqual(JSON.parse(env.ls.getItem('cs-settings-sync-base')).gen, env.server.gen);
|
|
assert.deepStrictEqual(env.toasts, ['Your settings are now saved to your account.']);
|
|
assert.strictEqual(env.ls.getItem('cs-settings-sync-rev'), '1');
|
|
assert.strictEqual(JSON.parse(env.ls.getItem('cs-settings-sync-base')).user, 7);
|
|
});
|
|
|
|
test('first login with nothing to upload sends nothing', async () => {
|
|
const env = makeEnv({ user: null });
|
|
await env.login({ id: 7 });
|
|
assert.strictEqual(env.server.puts.length, 0);
|
|
assert.strictEqual(env.t.state.status, 'idle');
|
|
});
|
|
|
|
test('login on another device merges the profile, applies theme and customizer, re-renders', async () => {
|
|
const server = serverWith(4, { 'meshcore-favorites': J(['a']), 'meshcore-theme': 'dark', 'cs-theme-overrides': '{"x":1}' });
|
|
const env = makeEnv({ user: null, server, local: { 'meshcore-favorites': J(['b']) } });
|
|
await env.login({ id: 7 });
|
|
assert.strictEqual(env.ls.getItem('meshcore-favorites'), J(['a', 'b']));
|
|
assert.strictEqual(env.ls.getItem('meshcore-theme'), 'dark');
|
|
assert(env.events.some((e) => e.type === 'storage' && e.key === 'meshcore-theme' && e.newValue === 'dark'));
|
|
assert.strictEqual(env.pipelines, 1);
|
|
assert.strictEqual(env.navigations, 1);
|
|
// M3: the first login names the upload, not "updated from another device".
|
|
assert.deepStrictEqual(env.toasts, ['Your settings are now saved to your account.']);
|
|
// b was only here: pushed on top of revision 4.
|
|
assert.strictEqual(server.puts.length, 1);
|
|
assert.strictEqual(server.puts[0].baseRevision, 4);
|
|
assert.strictEqual(server.doc.keys['meshcore-favorites'], J(['a', 'b']));
|
|
});
|
|
|
|
test('first login with nothing new here: the first-login toast, nothing pushed', async () => {
|
|
const server = serverWith(4, { 'meshcore-favorites': J(['a']), 'meshcore-time-window': '180' });
|
|
const env = makeEnv({ user: null, server, local: { 'meshcore-favorites': J(['a']) } });
|
|
await env.login({ id: 7 });
|
|
assert.strictEqual(env.ls.getItem('meshcore-time-window'), '180');
|
|
assert.strictEqual(env.navigations, 1);
|
|
assert.strictEqual(server.puts.length, 0);
|
|
assert.deepStrictEqual(env.toasts, ['Your settings are now saved to your account.']);
|
|
});
|
|
|
|
test('remote tile providers reach the map through a storage event', async () => {
|
|
const server = serverWith(2, { 'mc-dark-tile-provider': 'carto-dark', 'mc-light-tile-provider': 'osm' });
|
|
const env = makeEnv({ server, local: synced({}, 1) });
|
|
await env.timers.advance(0);
|
|
assert(env.events.some((e) => e.type === 'storage' && e.key === 'mc-dark-tile-provider' && e.newValue === 'carto-dark'));
|
|
assert(env.events.some((e) => e.type === 'storage' && e.key === 'mc-light-tile-provider' && e.newValue === 'osm'));
|
|
});
|
|
|
|
// M1 (final review): app.js ignores a storage event without a value, so a
|
|
// remote removal applies the default the app starts with: the OS colour
|
|
// scheme for the theme, no preset (clearPreset) for the colour-blind preset.
|
|
test('a theme removed on another device falls back to the OS colour scheme', async () => {
|
|
for (const prefersDark of [false, true]) {
|
|
const env = makeEnv({ prefersDark, server: serverWith(2, {}), local: synced({ 'meshcore-theme': 'dark' }, 1) });
|
|
await env.timers.advance(0);
|
|
assert.strictEqual(env.ls.getItem('meshcore-theme'), null);
|
|
const ev = env.events.filter((e) => e.type === 'storage' && e.key === 'meshcore-theme');
|
|
assert.deepStrictEqual(ev.map((e) => e.newValue), [prefersDark ? 'dark' : 'light']);
|
|
}
|
|
});
|
|
|
|
test('a colour-blind preset removed on another device is cleared through cb-presets', async () => {
|
|
const env = makeEnv({ server: serverWith(2, {}), local: synced({ 'meshcore-cb-preset': 'wong' }, 1) });
|
|
await env.timers.advance(0);
|
|
assert.strictEqual(env.ls.getItem('meshcore-cb-preset'), null);
|
|
assert.strictEqual(env.presetClears, 1);
|
|
assert(!env.events.some((e) => e.type === 'storage' && e.key === 'meshcore-cb-preset'));
|
|
assert.strictEqual(env.server.puts.length, 0);
|
|
});
|
|
|
|
// Polish 2: the tile listener ignores a removal, so a removed provider is
|
|
// re-applied as the effective one (the way customize-v2.js resetAll does)
|
|
// and the key stays unset.
|
|
const TILES = { 'mc-dark-tile-provider': 'osm-dark', 'mc-light-tile-provider': 'osm' };
|
|
test('tile providers removed on another device fall back to the effective provider', async () => {
|
|
const env = makeEnv({ server: serverWith(2, {}), local: synced(TILES, 1) });
|
|
await env.timers.advance(0);
|
|
assert.deepStrictEqual(plain(env.tileSets), [['dark', 'carto-dark'], ['light', 'carto-light']]);
|
|
assert.strictEqual(env.ls.getItem('mc-dark-tile-provider'), null);
|
|
assert.strictEqual(env.ls.getItem('mc-light-tile-provider'), null);
|
|
assert.strictEqual(env.t.state.seq, 0, 're-applying the default counted as a change');
|
|
await env.timers.advance(10000);
|
|
assert.strictEqual(env.server.puts.length, 0);
|
|
});
|
|
|
|
test('logout dialog: remove re-applies the effective tile providers', async () => {
|
|
const env = makeEnv({ server: serverWith(1, TILES), local: synced(TILES, 1) });
|
|
await env.timers.advance(0);
|
|
env.t.useDialog(() => Promise.resolve('remove'));
|
|
(await env.logoutHandler()).afterLogout();
|
|
assert.deepStrictEqual(plain(env.tileSets), [['dark', 'carto-dark'], ['light', 'carto-light']]);
|
|
assert.strictEqual(env.ls.getItem('mc-dark-tile-provider'), null);
|
|
assert.strictEqual(env.ls.getItem('mc-light-tile-provider'), null);
|
|
await env.timers.advance(10000);
|
|
assert.strictEqual(env.server.puts.length, 0);
|
|
});
|
|
|
|
test('customizer not initialised yet: its pipeline is not run (its init reads the new values)', async () => {
|
|
const server = serverWith(2, { 'cs-theme-overrides': '{"x":1}' });
|
|
const env = makeEnv({ server, local: synced({}, 1), customizerReady: false });
|
|
await env.timers.advance(0);
|
|
assert.strictEqual(env.ls.getItem('cs-theme-overrides'), '{"x":1}');
|
|
assert.strictEqual(env.pipelines, 0);
|
|
assert.strictEqual(env.navigations, 1);
|
|
});
|
|
|
|
test('interception: allowlisted writes push once, 2 s after the last; other keys never', async () => {
|
|
const keys = { 'meshcore-favorites': J(['a']) };
|
|
const env = makeEnv({ server: serverWith(1, keys), local: synced(keys, 1) });
|
|
await env.timers.advance(0);
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
env.ls.setItem('meshcore-time-window', '180');
|
|
env.ls.setItem('corescope_channel_keys', '{"#x":"00"}');
|
|
env.ls.setItem('meshcore-api-key', 'k');
|
|
env.ls.setItem('panel-drag-packets', '1');
|
|
await env.timers.advance(1999);
|
|
assert.strictEqual(env.server.puts.length, 0);
|
|
await env.timers.advance(1);
|
|
assert.strictEqual(env.server.puts.length, 1);
|
|
assert.deepStrictEqual(env.server.puts[0].doc.keys, { 'meshcore-favorites': J(['a']), 'meshcore-time-window': '180' });
|
|
env.ls.removeItem('meshcore-time-window');
|
|
await env.timers.advance(2000);
|
|
assert.strictEqual(env.server.puts.length, 2);
|
|
env.ls.setItem('corescope_channel_keys', '{}');
|
|
await env.timers.advance(10000);
|
|
assert.strictEqual(env.server.puts.length, 2);
|
|
});
|
|
|
|
test('writing the same value again does not push', async () => {
|
|
const keys = { 'meshcore-theme': 'dark' };
|
|
const env = makeEnv({ server: serverWith(1, keys), local: synced(keys, 1) });
|
|
await env.timers.advance(0);
|
|
env.ls.setItem('meshcore-theme', 'dark');
|
|
env.ls.removeItem('meshcore-time-window');
|
|
await env.timers.advance(10000);
|
|
assert.strictEqual(env.server.puts.length, 0);
|
|
});
|
|
|
|
test('values applied from the account do not trigger a push', async () => {
|
|
const env = makeEnv({ server: serverWith(2, { 'meshcore-time-window': '180' }), local: synced({ 'meshcore-time-window': '60' }, 1) });
|
|
await env.timers.advance(10000);
|
|
assert.strictEqual(env.ls.getItem('meshcore-time-window'), '180');
|
|
assert.strictEqual(env.server.puts.length, 0);
|
|
});
|
|
|
|
test('409: merges the returned document and retries on top of it', async () => {
|
|
const keys = { 'meshcore-favorites': J(['a']) };
|
|
const server = serverWith(1, keys);
|
|
const env = makeEnv({ server, local: synced(keys, 1) });
|
|
await env.timers.advance(0);
|
|
server.rev = 2;
|
|
server.doc = { v: 1, keys: { 'meshcore-favorites': J(['a', 'c']) } }; // another device
|
|
env.ls.setItem('meshcore-favorites', J(['a', 'b']));
|
|
await env.timers.advance(2000);
|
|
assert.strictEqual(server.puts.length, 2);
|
|
assert.strictEqual(server.puts[1].baseRevision, 2);
|
|
assert.strictEqual(server.doc.keys['meshcore-favorites'], J(['a', 'c', 'b']));
|
|
assert.strictEqual(env.ls.getItem('meshcore-favorites'), J(['a', 'c', 'b']));
|
|
});
|
|
|
|
test('409 more than three times falls back to the retry backoff', async () => {
|
|
const keys = { 'meshcore-favorites': J(['a']) };
|
|
const server = serverWith(1, keys);
|
|
const env = makeEnv({ server, local: synced(keys, 1) });
|
|
await env.timers.advance(0);
|
|
const conflict = { status: 409, data: { revision: 1, generation: 'g0', doc: { v: 1, keys } } };
|
|
server.fail.PUT = [conflict, conflict, conflict, conflict];
|
|
env.ls.setItem('meshcore-favorites', J(['a', 'b']));
|
|
await env.timers.advance(2000);
|
|
assert.strictEqual(server.puts.length, 4);
|
|
assert.strictEqual(env.t.state.status, 'retrying');
|
|
assert.deepStrictEqual(env.timers.delays(), [2000]);
|
|
});
|
|
|
|
test('network errors, 5xx and 429 back off from 2 s doubling to 5 min; success resets', async () => {
|
|
const keys = { 'meshcore-favorites': J(['a']) };
|
|
const server = serverWith(1, keys);
|
|
const env = makeEnv({ server, local: synced(keys, 1) });
|
|
env.doc.visibilityState = 'hidden'; // no minute pulls in between
|
|
await env.timers.advance(0);
|
|
const expected = [2000, 4000, 8000, 16000, 32000, 64000, 128000, 256000, 300000];
|
|
server.fail.PUT = expected.map((_, i) => ['network', { status: 503, data: {} }, { status: 429, data: {} }][i % 3]);
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
await env.timers.advance(2000);
|
|
for (const d of expected) {
|
|
assert.deepStrictEqual(env.timers.delays(), [d]);
|
|
assert.strictEqual(env.t.state.status, 'retrying');
|
|
await env.timers.advance(d);
|
|
}
|
|
assert.strictEqual(env.t.state.status, 'ok');
|
|
assert.strictEqual(env.t.state.backoff, 2000);
|
|
assert.strictEqual(server.doc.keys['meshcore-time-window'], '60');
|
|
});
|
|
|
|
// M7 (final review): the baseline is up to 256 KiB; an unchanged pull
|
|
// must not rewrite it every minute.
|
|
test('a pull with nothing new does not rewrite the baseline', async () => {
|
|
const keys = { 'meshcore-favorites': J(['a']) };
|
|
const shared = new Map();
|
|
const set = shared.set.bind(shared);
|
|
let baseWrites = 0;
|
|
shared.set = (k, v) => { if (k === 'cs-settings-sync-base') baseWrites++; return set(k, v); };
|
|
const server = serverWith(1, keys);
|
|
const env = makeEnv({ server, shared, local: synced(keys, 1) });
|
|
baseWrites = 0;
|
|
await env.timers.advance(180000);
|
|
assert(server.gets >= 4, 'gets ' + server.gets);
|
|
assert.strictEqual(baseWrites, 0);
|
|
server.rev = 2;
|
|
server.doc = { v: 1, keys: { 'meshcore-favorites': J(['a', 'b']) } };
|
|
await env.timers.advance(60000);
|
|
assert.strictEqual(baseWrites, 1);
|
|
assert.strictEqual(shared.get('cs-settings-sync-rev'), '2');
|
|
});
|
|
|
|
test('tab focus pulls; the minute pull runs only while visible', async () => {
|
|
const env = makeEnv({ server: serverWith(1, {}), local: synced({}, 1) });
|
|
await env.timers.advance(0);
|
|
const g0 = env.server.gets;
|
|
await env.fireDoc('visibilitychange');
|
|
assert.strictEqual(env.server.gets, g0 + 1);
|
|
await env.timers.advance(60000);
|
|
assert.strictEqual(env.server.gets, g0 + 2);
|
|
env.doc.visibilityState = 'hidden';
|
|
await env.timers.advance(180000);
|
|
assert.strictEqual(env.server.gets, g0 + 2);
|
|
});
|
|
|
|
test('413 stops pushing and names the largest keys; Sync now tries again', async () => {
|
|
const server = serverWith(1, {});
|
|
const env = makeEnv({ server, local: synced({}, 1) });
|
|
await env.timers.advance(0);
|
|
server.fail.PUT = [{ status: 413, data: { error: 'too large' } }];
|
|
env.ls.setItem('cs-theme-overrides', 'x'.repeat(100));
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
await env.timers.advance(2000);
|
|
assert.strictEqual(env.t.state.status, 'too-large');
|
|
assert.strictEqual(env.t.state.tooLarge[0], 'cs-theme-overrides');
|
|
env.ls.setItem('meshcore-time-window', '15');
|
|
await env.timers.advance(10000);
|
|
assert.strictEqual(server.puts.length, 1);
|
|
await env.api.syncNow();
|
|
await settle();
|
|
assert.strictEqual(server.puts.length, 2);
|
|
assert.strictEqual(env.t.state.status, 'ok');
|
|
});
|
|
|
|
test('400 stops pushing until reload and logs the reason', async () => {
|
|
const server = serverWith(1, {});
|
|
const env = makeEnv({ server, local: synced({}, 1) });
|
|
await env.timers.advance(0);
|
|
server.fail.PUT = [{ status: 400, data: { error: 'key "x" is not a synced setting' } }];
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
await env.timers.advance(2000);
|
|
assert.strictEqual(env.t.state.status, 'rejected');
|
|
assert.strictEqual(env.errors.length, 1);
|
|
env.ls.setItem('meshcore-time-window', '15');
|
|
await env.api.syncNow();
|
|
await env.timers.advance(10000);
|
|
assert.strictEqual(server.puts.length, 1);
|
|
});
|
|
|
|
// M6 (final review): after a logout and login in another tab this tab's
|
|
// CSRF token is stale and every PUT answers 403; retrying never ends.
|
|
// Polish 1: a login in another tab rotates the CSRF token; refreshMe stores
|
|
// the new one, so the PUT is retried once with it.
|
|
test('403 on a push: same user, retried once with the refreshed token', async () => {
|
|
const server = serverWith(1, {});
|
|
const env = makeEnv({ server, local: synced({}, 1) });
|
|
await env.timers.advance(0);
|
|
server.fail.PUT = [{ status: 403, data: { error: 'missing CSRF token' } }];
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
await env.timers.advance(2000);
|
|
assert.strictEqual(env.refreshes, 1);
|
|
assert.strictEqual(server.puts.length, 2);
|
|
assert.strictEqual(server.doc.keys['meshcore-time-window'], '60');
|
|
assert.strictEqual(env.t.state.blocked, null);
|
|
assert.strictEqual(env.t.state.status, 'ok');
|
|
});
|
|
|
|
test('403 on a push: same user, a second 403 stops syncing until a reload', async () => {
|
|
const server = serverWith(1, {});
|
|
const env = makeEnv({ server, local: synced({}, 1) });
|
|
await env.timers.advance(0);
|
|
server.fail.PUT = [{ status: 403, data: { error: 'missing CSRF token' } }, { status: 403, data: { error: 'bad origin' } }];
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
await env.timers.advance(2000);
|
|
assert.strictEqual(env.refreshes, 2);
|
|
assert.strictEqual(server.puts.length, 2);
|
|
assert.strictEqual(env.t.state.blocked, 'forbidden');
|
|
assert.strictEqual(env.t.state.status, 'forbidden');
|
|
assert.strictEqual(env.t.statusText(), 'Not synced: this tab is out of date. Reload the page to sync again.');
|
|
assert.deepStrictEqual(env.timers.delays(), [], 'a retry is still scheduled');
|
|
env.ls.setItem('meshcore-time-window', '15');
|
|
await env.timers.advance(600000);
|
|
assert.strictEqual(server.puts.length, 2);
|
|
});
|
|
|
|
test('403 on a push: another user logged in since re-activates for that user', async () => {
|
|
const server = serverWith(1, {});
|
|
const env = makeEnv({ server, local: synced({}, 1) });
|
|
await env.timers.advance(0);
|
|
server.fail.PUT = [{ status: 403, data: { error: 'missing CSRF token' } }];
|
|
env.nextMe = { id: 8 };
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
await env.timers.advance(2000);
|
|
assert.strictEqual(env.refreshes, 1);
|
|
assert.strictEqual(env.t.state.userId, 8);
|
|
assert.notStrictEqual(env.t.state.status, 'forbidden');
|
|
assert.strictEqual(env.t.state.blocked, null);
|
|
});
|
|
|
|
test('403 on a pull: logged out since deactivates', async () => {
|
|
const server = serverWith(1, {});
|
|
const env = makeEnv({ server, local: synced({}, 1) });
|
|
await env.timers.advance(0);
|
|
server.fail.GET = [{ status: 403, data: { error: 'forbidden' } }];
|
|
env.nextMe = null;
|
|
await env.fireDoc('visibilitychange');
|
|
assert.strictEqual(env.refreshes, 1);
|
|
assert.strictEqual(env.t.state.active, false);
|
|
});
|
|
|
|
test('account copy deleted elsewhere: values stay, no upload until the next change', async () => {
|
|
const keys = { 'meshcore-favorites': J(['a']) };
|
|
const env = makeEnv({ server: serverWith(0, null), local: synced(keys, 3) });
|
|
await env.timers.advance(60000);
|
|
assert.strictEqual(env.server.puts.length, 0);
|
|
assert.strictEqual(env.ls.getItem('meshcore-favorites'), J(['a']));
|
|
assert.strictEqual(JSON.parse(env.ls.getItem('cs-settings-sync-base')).hold, true);
|
|
assert.strictEqual(env.ls.getItem('cs-settings-sync-rev'), '0');
|
|
env.ls.setItem('meshcore-favorites', J(['a', 'b']));
|
|
await env.timers.advance(2000);
|
|
assert.strictEqual(env.server.puts.length, 1);
|
|
assert.deepStrictEqual(env.server.puts[0], { baseRevision: 0, baseGeneration: '', doc: { v: 1, keys: { 'meshcore-favorites': J(['a', 'b']) } } });
|
|
});
|
|
|
|
// C2 (final review): revisions restart at 1 after a delete, so the baseline
|
|
// carries the document's generation; a baseline of another generation
|
|
// counts as none (union, nothing dropped).
|
|
test('a copy deleted and started again elsewhere: the old baseline drops nothing here', async () => {
|
|
// Synced revision 5 of generation g0, then closed. Meanwhile the copy was
|
|
// deleted and a new phone uploaded [p] as revision 1 of generation g9.
|
|
const server = serverWith(1, { 'meshcore-favorites': J(['p']) }, 'g9');
|
|
const env = makeEnv({ server, local: synced({ 'meshcore-favorites': J(['a', 'b']) }, 5) });
|
|
await env.timers.advance(0);
|
|
assert.strictEqual(env.ls.getItem('meshcore-favorites'), J(['p', 'a', 'b']));
|
|
assert.strictEqual(server.puts.length, 1);
|
|
assert.strictEqual(server.puts[0].baseRevision, 1);
|
|
assert.strictEqual(server.puts[0].baseGeneration, 'g9');
|
|
assert.strictEqual(server.doc.keys['meshcore-favorites'], J(['p', 'a', 'b']));
|
|
assert.strictEqual(JSON.parse(env.ls.getItem('cs-settings-sync-base')).gen, 'g9');
|
|
});
|
|
|
|
test('an offline retry from an old generation at the same revision gets 409 and merges', async () => {
|
|
const server = serverWith(2, { 'meshcore-favorites': J(['a']) });
|
|
const env = makeEnv({ server, local: synced({ 'meshcore-favorites': J(['a']) }, 2) });
|
|
env.doc.visibilityState = 'hidden'; // no pulls: only the retry talks to the server
|
|
await env.timers.advance(0);
|
|
server.fail.PUT = ['network'];
|
|
env.ls.setItem('meshcore-favorites', J(['a', 'b']));
|
|
await env.timers.advance(2000);
|
|
assert.strictEqual(env.t.state.status, 'retrying');
|
|
// Meanwhile: deleted, and a new copy reached revision 2 again.
|
|
server.gen = 'g9';
|
|
server.doc = { v: 1, keys: { 'meshcore-favorites': J(['c', 'd']) } };
|
|
await env.timers.advance(2000);
|
|
const tried = server.puts[1];
|
|
assert.deepStrictEqual([tried.baseRevision, tried.baseGeneration], [2, 'g0']);
|
|
assert.strictEqual(server.rev, 3);
|
|
assert.strictEqual(server.doc.keys['meshcore-favorites'], J(['c', 'd', 'a', 'b']));
|
|
assert.strictEqual(env.ls.getItem('meshcore-favorites'), J(['c', 'd', 'a', 'b']));
|
|
});
|
|
|
|
test("another user's baseline counts as none: nothing is removed", async () => {
|
|
const local = Object.assign({ 'meshcore-favorites': J(['a', 'b']) }, BASE(99, { 'meshcore-favorites': J(['a', 'b', 'c']) }, 5));
|
|
const env = makeEnv({ server: serverWith(2, { 'meshcore-favorites': J(['c']) }), local });
|
|
await env.timers.advance(0);
|
|
assert.strictEqual(env.ls.getItem('meshcore-favorites'), J(['c', 'a', 'b']));
|
|
});
|
|
|
|
test('mid-edit: on an account page or with the geofilter editor open, no re-render', async () => {
|
|
const env = makeEnv({ hash: '#/account', server: serverWith(2, { 'meshcore-time-window': '180' }), local: synced({ 'meshcore-time-window': '60' }, 1) });
|
|
await env.timers.advance(0);
|
|
assert.strictEqual(env.ls.getItem('meshcore-time-window'), '180');
|
|
assert.strictEqual(env.navigations, 0);
|
|
assert(env.toasts.indexOf('Settings updated from another device') !== -1);
|
|
env.ctx.location.hash = '#/packets';
|
|
assert.strictEqual(env.t.midEdit(), false);
|
|
env.el('cv2-gf-modal-overlay');
|
|
assert.strictEqual(env.t.midEdit(), true);
|
|
env.ctx.location.hash = '#/accounts-other';
|
|
delete env.els['cv2-gf-modal-overlay'];
|
|
assert.strictEqual(env.t.midEdit(), false);
|
|
});
|
|
|
|
test('logout makes the module inert: wrap removed, no timers, no requests', async () => {
|
|
const env = makeEnv({ server: serverWith(1, {}), local: synced({}, 1) });
|
|
await env.timers.advance(0);
|
|
assert.notStrictEqual(env.win.Storage.prototype.setItem, env.originalSet);
|
|
await env.logout();
|
|
assert.strictEqual(env.win.Storage.prototype.setItem, env.originalSet);
|
|
const before = env.server.gets + env.server.puts.length;
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
await env.timers.advance(180000);
|
|
assert.strictEqual(env.server.gets + env.server.puts.length, before);
|
|
assert.deepStrictEqual(env.timers.delays(), []);
|
|
});
|
|
|
|
// holdNext delays the answer to the next request of method until the
|
|
// returned release() is called. The fake server still handles it at send
|
|
// time, so the held answer reflects the account as it was then.
|
|
function holdNext(env, method) {
|
|
const orig = env.win.CSAuth.request;
|
|
let release, used = false;
|
|
const gate = new Promise((r) => { release = r; });
|
|
env.win.CSAuth.request = function (m, p, b) {
|
|
const res = orig.call(this, m, p, b);
|
|
if (m !== method || used) return res;
|
|
used = true;
|
|
return gate.then(() => res);
|
|
};
|
|
return () => { release(); return settle(); };
|
|
}
|
|
|
|
test('a pull answered after a successful push does not revert the local change', async () => {
|
|
const env = makeEnv({ server: serverWith(1, { 'meshcore-time-window': '60' }), local: synced({ 'meshcore-time-window': '60' }, 1) });
|
|
await env.timers.advance(0);
|
|
const release = holdNext(env, 'GET');
|
|
await env.fireDoc('visibilitychange'); // GET sent, answer (revision 1) held
|
|
env.ls.setItem('meshcore-time-window', '180');
|
|
await env.timers.advance(2000);
|
|
assert.strictEqual(env.server.rev, 2);
|
|
await release();
|
|
assert.strictEqual(env.ls.getItem('meshcore-time-window'), '180');
|
|
assert.strictEqual(env.ls.getItem('cs-settings-sync-rev'), '2');
|
|
assert.strictEqual(env.navigations, 0);
|
|
assert.strictEqual(env.toasts.indexOf('Settings updated from another device'), -1);
|
|
});
|
|
|
|
test('a revision-0 pull answered after the first upload does not enter hold', async () => {
|
|
const env = makeEnv({ user: null, local: { 'meshcore-favorites': J(['a']) } });
|
|
await settle();
|
|
const release = holdNext(env, 'GET');
|
|
const loggedIn = env.login({ id: 7 }); // first GET held
|
|
env.fireDoc('visibilitychange'); // second GET answered at once: revision 0, first upload
|
|
await loggedIn;
|
|
assert.strictEqual(env.server.rev, 1);
|
|
await release();
|
|
assert.strictEqual(JSON.parse(env.ls.getItem('cs-settings-sync-base')).hold, false);
|
|
assert.strictEqual(env.ls.getItem('cs-settings-sync-rev'), '1');
|
|
assert.strictEqual(env.t.state.status, 'ok');
|
|
});
|
|
|
|
test('a push answered after logout and login again does not touch the new session', async () => {
|
|
const env = makeEnv({ server: serverWith(1, {}), local: synced({}, 1) });
|
|
await env.timers.advance(0);
|
|
const releaseOld = holdNext(env, 'PUT');
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
await env.timers.advance(2000); // old session PUT held
|
|
await env.logout();
|
|
await env.login({ id: 7 });
|
|
const releaseNew = holdNext(env, 'PUT');
|
|
env.ls.setItem('meshcore-time-window', '15');
|
|
await env.timers.advance(2000); // new session PUT held
|
|
const inFlight = env.t.state.pushing;
|
|
assert(inFlight);
|
|
await releaseOld();
|
|
assert.strictEqual(env.t.state.pushing, inFlight);
|
|
assert.strictEqual(env.t.state.status, 'syncing');
|
|
await releaseNew();
|
|
assert.strictEqual(env.t.state.pushing, null);
|
|
assert.strictEqual(env.ls.getItem('cs-settings-sync-rev'), '3');
|
|
assert.strictEqual(JSON.parse(env.ls.getItem('cs-settings-sync-base')).keys['meshcore-time-window'], '15');
|
|
});
|
|
|
|
// I1 (final review): two tabs share localStorage, so the stored baseline is
|
|
// the truth for both; a tab never merges against its own stale copy.
|
|
test('two tabs: a removal from another device is not undone by a tab with an old baseline', async () => {
|
|
const keys = { 'meshcore-favorites': J(['a']) };
|
|
const server = serverWith(2, keys);
|
|
const shared = new Map();
|
|
const tab2 = makeEnv({ server, shared, local: synced(keys, 2) });
|
|
await tab2.timers.advance(0);
|
|
const tab1 = makeEnv({ server, shared });
|
|
await tab1.timers.advance(0);
|
|
tab1.ls.setItem('meshcore-favorites', J(['a', 'X']));
|
|
await tab1.timers.advance(2000);
|
|
assert.strictEqual(server.rev, 3);
|
|
// A phone removes X.
|
|
server.rev = 4;
|
|
server.doc = { v: 1, keys: { 'meshcore-favorites': J(['a']) } };
|
|
await tab2.fireDoc('visibilitychange');
|
|
assert.strictEqual(shared.get('meshcore-favorites'), J(['a']));
|
|
assert.strictEqual(server.doc.keys['meshcore-favorites'], J(['a']));
|
|
assert.strictEqual(server.rev, 4);
|
|
});
|
|
|
|
test('two tabs: a pull answered after the other tab pushed does not drop that change', async () => {
|
|
const keys = { 'meshcore-favorites': J(['a']) };
|
|
const server = serverWith(2, keys);
|
|
const shared = new Map();
|
|
const tab2 = makeEnv({ server, shared, local: synced(keys, 2) });
|
|
await tab2.timers.advance(0);
|
|
const tab1 = makeEnv({ server, shared });
|
|
await tab1.timers.advance(0);
|
|
const release = holdNext(tab2, 'GET');
|
|
await tab2.fireDoc('visibilitychange'); // GET sent: revision 2, answer held
|
|
tab1.ls.setItem('meshcore-favorites', J(['a', 'X']));
|
|
await tab1.timers.advance(2000);
|
|
assert.strictEqual(server.rev, 3);
|
|
await release();
|
|
assert.strictEqual(shared.get('meshcore-favorites'), J(['a', 'X']));
|
|
assert.strictEqual(server.doc.keys['meshcore-favorites'], J(['a', 'X']));
|
|
});
|
|
|
|
// Polish 3: range inputs write many times per second; a write must not
|
|
// parse the stored baseline (up to 256 KiB) to learn the hold flag.
|
|
test('allowlisted writes do not read the stored baseline', async () => {
|
|
const keys = { 'meshcore-favorites': J(['a']), 'cs-theme-overrides': J({ big: 'x'.repeat(10000) }) };
|
|
const env = makeEnv({ server: serverWith(1, keys), local: synced(keys, 1) });
|
|
await env.timers.advance(0);
|
|
let reads = 0;
|
|
const get = env.ls.m.get.bind(env.ls.m);
|
|
env.ls.m.get = (k) => { if (k === 'cs-settings-sync-base') reads++; return get(k); };
|
|
for (let i = 0; i < 50; i++) env.ls.setItem('meshcore-time-window', String(i));
|
|
assert.strictEqual(reads, 0);
|
|
env.ls.m.get = get;
|
|
await env.timers.advance(2000);
|
|
assert.strictEqual(env.server.doc.keys['meshcore-time-window'], '49');
|
|
});
|
|
|
|
test('two tabs: a change in one tab ends the hold the other tab entered', async () => {
|
|
const server = serverWith(1, KEYS);
|
|
const shared = new Map();
|
|
const tab2 = makeEnv({ server, shared, local: synced(KEYS, 1) });
|
|
await tab2.timers.advance(0);
|
|
const tab1 = makeEnv({ server, shared });
|
|
await tab1.timers.advance(0);
|
|
assert.strictEqual((await tab2.t.deleteRemote()).ok, true);
|
|
assert.strictEqual(JSON.parse(shared.get('cs-settings-sync-base')).hold, true);
|
|
tab1.ls.setItem('meshcore-time-window', '60');
|
|
assert.strictEqual(JSON.parse(shared.get('cs-settings-sync-base')).hold, false);
|
|
await tab1.timers.advance(2000);
|
|
assert.strictEqual(server.rev, 1);
|
|
assert.strictEqual(server.puts[server.puts.length - 1].baseRevision, 0);
|
|
assert.strictEqual(server.doc.keys['meshcore-time-window'], '60');
|
|
});
|
|
|
|
// ── logout dialog and account section ──
|
|
const KEYS = { 'meshcore-favorites': J(['a']) };
|
|
|
|
test('the logout handler is registered only while active', async () => {
|
|
const off = makeEnv({ enabled: false });
|
|
await settle();
|
|
assert.strictEqual(off.logoutHandler, null);
|
|
const env = makeEnv({ server: serverWith(1, KEYS), local: synced(KEYS, 1) });
|
|
await env.timers.advance(0);
|
|
assert.strictEqual(typeof env.logoutHandler, 'function');
|
|
await env.logout();
|
|
assert.strictEqual(env.logoutHandler, null);
|
|
});
|
|
|
|
test('logout dialog: keep first and focused, channel-key text, three choices', async () => {
|
|
const env = makeEnv({ server: serverWith(1, KEYS), local: synced(KEYS, 1) });
|
|
await env.timers.advance(0);
|
|
let seen = null;
|
|
env.t.useDialog((opts) => { seen = plain(opts); return Promise.resolve('keep'); });
|
|
await env.logoutHandler();
|
|
assert.deepStrictEqual(seen.choices.map((c) => c.id), ['keep', 'remove', 'cancel']);
|
|
assert.strictEqual(seen.choices[0].label, 'Keep my settings on this device');
|
|
assert.strictEqual(seen.choices[0].primary, true);
|
|
assert.strictEqual(seen.choices[1].label, 'Remove my settings from this device');
|
|
assert(seen.text.join(' ').indexOf('Channel keys are never synced') !== -1);
|
|
});
|
|
|
|
test('logout dialog: keep pushes pending changes first and leaves local data', async () => {
|
|
const env = makeEnv({ server: serverWith(1, KEYS), local: synced(KEYS, 1) });
|
|
await env.timers.advance(0);
|
|
env.ls.setItem('meshcore-time-window', '60'); // still in the 2 s debounce
|
|
env.t.useDialog(() => Promise.resolve('keep'));
|
|
const h = plain(await env.logoutHandler());
|
|
assert.deepStrictEqual(h, {});
|
|
assert.strictEqual(env.server.puts.length, 1);
|
|
assert.strictEqual(env.server.doc.keys['meshcore-time-window'], '60');
|
|
assert.strictEqual(env.ls.getItem('meshcore-time-window'), '60');
|
|
});
|
|
|
|
test('logout dialog: remove deletes synced keys and the baseline, never channel keys', async () => {
|
|
const local = Object.assign({ 'meshcore-time-window': '60', corescope_channel_keys: '{"#x":"00"}', 'meshcore-api-key': 'k' }, synced(KEYS, 1));
|
|
const env = makeEnv({ server: serverWith(1, KEYS), local });
|
|
await env.timers.advance(0);
|
|
env.t.useDialog(() => Promise.resolve('remove'));
|
|
const h = await env.logoutHandler();
|
|
assert.strictEqual(typeof h.afterLogout, 'function');
|
|
h.afterLogout();
|
|
for (const k of ['meshcore-favorites', 'meshcore-time-window', 'cs-settings-sync-base', 'cs-settings-sync-rev']) {
|
|
assert.strictEqual(env.ls.getItem(k), null, k + ' left behind');
|
|
}
|
|
assert.strictEqual(env.ls.getItem('corescope_channel_keys'), '{"#x":"00"}');
|
|
assert.strictEqual(env.ls.getItem('meshcore-api-key'), 'k');
|
|
});
|
|
|
|
// M2 (final review): removing the keys alone left the theme and the
|
|
// customizer CSS applied until a reload.
|
|
test('logout dialog: remove shows the defaults at once', async () => {
|
|
const keys = { 'meshcore-theme': 'dark', 'meshcore-cb-preset': 'wong', 'cs-theme-overrides': '{"x":1}' };
|
|
const env = makeEnv({ server: serverWith(1, keys), local: synced(keys, 1) });
|
|
await env.timers.advance(0);
|
|
env.t.useDialog(() => Promise.resolve('remove'));
|
|
const h = await env.logoutHandler();
|
|
const p0 = env.pipelines;
|
|
h.afterLogout();
|
|
assert.strictEqual(env.resets, 1, 'customizer Reset All teardown not run');
|
|
assert.strictEqual(env.pipelines, p0 + 1);
|
|
assert.strictEqual(env.presetClears, 1);
|
|
assert(env.events.some((e) => e.type === 'storage' && e.key === 'meshcore-theme' && e.newValue === 'light'));
|
|
assert.strictEqual(env.ls.getItem('cs-theme-overrides'), null);
|
|
await env.timers.advance(10000);
|
|
assert.strictEqual(env.server.puts.length, 0, 'the teardown pushed');
|
|
});
|
|
|
|
test('logout dialog: remove before the customizer finished its init runs no pipeline', async () => {
|
|
const env = makeEnv({ customizerReady: false, server: serverWith(1, KEYS), local: synced(KEYS, 1) });
|
|
await env.timers.advance(0);
|
|
env.t.useDialog(() => Promise.resolve('remove'));
|
|
(await env.logoutHandler()).afterLogout();
|
|
assert.strictEqual(env.resets + env.pipelines, 0);
|
|
});
|
|
|
|
test('logout dialog: remove after a failed push keeps the data and says so', async () => {
|
|
const server = serverWith(1, KEYS);
|
|
const env = makeEnv({ server, local: synced(KEYS, 1) });
|
|
await env.timers.advance(0);
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
server.fail.PUT = ['network'];
|
|
env.t.useDialog(() => Promise.resolve('remove'));
|
|
const h = await env.logoutHandler();
|
|
h.afterLogout();
|
|
assert.strictEqual(env.ls.getItem('meshcore-time-window'), '60');
|
|
assert.strictEqual(env.ls.getItem('meshcore-favorites'), J(['a']));
|
|
assert(env.toasts.indexOf('Your latest settings could not be saved to your account, so they stay on this device.') !== -1);
|
|
});
|
|
|
|
// Polish 4: unpushed changes are judged from storage, which every tab
|
|
// shares, not from this tab's dirty flag.
|
|
test('two tabs: logout remove pushes a change the other tab made seconds earlier', async () => {
|
|
const server = serverWith(1, KEYS);
|
|
const shared = new Map();
|
|
const tab1 = makeEnv({ server, shared, local: synced(KEYS, 1) });
|
|
await tab1.timers.advance(0);
|
|
const tab2 = makeEnv({ server, shared });
|
|
await tab2.timers.advance(0);
|
|
tab1.ls.setItem('meshcore-time-window', '60'); // tab1 still in its 2 s debounce
|
|
tab2.t.useDialog(() => Promise.resolve('remove'));
|
|
const h = await tab2.logoutHandler();
|
|
assert.strictEqual(server.doc.keys['meshcore-time-window'], '60');
|
|
h.afterLogout();
|
|
assert.strictEqual(shared.get('meshcore-time-window'), undefined);
|
|
});
|
|
|
|
test('two tabs: logout remove keeps the other tab change when its push fails', async () => {
|
|
const server = serverWith(1, KEYS);
|
|
const shared = new Map();
|
|
const tab1 = makeEnv({ server, shared, local: synced(KEYS, 1) });
|
|
await tab1.timers.advance(0);
|
|
const tab2 = makeEnv({ server, shared });
|
|
await tab2.timers.advance(0);
|
|
tab1.ls.setItem('meshcore-time-window', '60');
|
|
server.fail.PUT = ['network'];
|
|
tab2.t.useDialog(() => Promise.resolve('remove'));
|
|
(await tab2.logoutHandler()).afterLogout();
|
|
assert.strictEqual(shared.get('meshcore-time-window'), '60');
|
|
assert.strictEqual(shared.get('meshcore-favorites'), J(['a']));
|
|
assert(tab2.toasts.indexOf('Your latest settings could not be saved to your account, so they stay on this device.') !== -1);
|
|
});
|
|
|
|
test('logout remove while held: nothing is pushed, the account copy stays deleted', async () => {
|
|
const server = serverWith(1, KEYS);
|
|
const env = makeEnv({ server, local: synced(KEYS, 1) });
|
|
await env.timers.advance(0);
|
|
assert.strictEqual((await env.t.deleteRemote()).ok, true);
|
|
const puts = server.puts.length;
|
|
env.t.useDialog(() => Promise.resolve('remove'));
|
|
(await env.logoutHandler()).afterLogout();
|
|
assert.strictEqual(server.puts.length, puts);
|
|
assert.strictEqual(server.doc, null);
|
|
assert.strictEqual(env.ls.getItem('meshcore-favorites'), null);
|
|
});
|
|
|
|
// M5 (final review): a hanging connection must not hold the logout dialog.
|
|
test('logout dialog: a push that hangs counts as failed after 5 s', async () => {
|
|
for (const choice of ['remove', 'keep']) {
|
|
const env = makeEnv({ server: serverWith(1, KEYS), local: synced(KEYS, 1) });
|
|
await env.timers.advance(0);
|
|
holdNext(env, 'PUT'); // never released
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
env.t.useDialog(() => Promise.resolve(choice));
|
|
let h = null;
|
|
env.logoutHandler().then((r) => { h = r; });
|
|
await env.timers.advance(4999);
|
|
assert.strictEqual(h, null, choice + ': resolved before the timeout');
|
|
await env.timers.advance(1);
|
|
assert(h, choice + ': still waiting after 5 s');
|
|
if (choice === 'keep') { assert.deepStrictEqual(plain(h), {}); continue; }
|
|
h.afterLogout();
|
|
assert.strictEqual(env.ls.getItem('meshcore-time-window'), '60');
|
|
assert.strictEqual(env.ls.getItem('meshcore-favorites'), J(['a']));
|
|
assert(env.toasts.indexOf('Your latest settings could not be saved to your account, so they stay on this device.') !== -1);
|
|
assert(env.warnings.some((w) => w.indexOf('5 s') !== -1), 'timeout not logged');
|
|
}
|
|
});
|
|
|
|
test('logout dialog: Cancel, Escape or the backdrop cancel the logout', async () => {
|
|
const env = makeEnv({ server: serverWith(1, KEYS), local: synced(KEYS, 1) });
|
|
await env.timers.advance(0);
|
|
for (const choice of ['cancel', null]) {
|
|
env.t.useDialog(() => Promise.resolve(choice));
|
|
assert.deepStrictEqual(plain(await env.logoutHandler()), { cancel: true });
|
|
}
|
|
});
|
|
|
|
test('account section: status, Sync now, delete with confirmation', async () => {
|
|
const env = makeEnv({ server: serverWith(1, KEYS), local: synced(KEYS, 1) });
|
|
await env.timers.advance(0);
|
|
const el = env.el('syncSection');
|
|
env.api.mountSection(el);
|
|
assert(el.innerHTML.indexOf('id="syncNow"') !== -1 && el.innerHTML.indexOf('id="syncDelete"') !== -1);
|
|
assert(el.innerHTML.indexOf('Delete synced settings from my account') !== -1);
|
|
assert(env.els.syncStatus.textContent.indexOf('Last synced ') === 0, env.els.syncStatus.textContent);
|
|
const g0 = env.server.gets;
|
|
await env.els.syncNow.handlers.click();
|
|
await settle();
|
|
assert.strictEqual(env.server.gets, g0 + 1);
|
|
env.t.useDialog(() => Promise.resolve('cancel'));
|
|
await env.els.syncDelete.handlers.click();
|
|
assert.strictEqual(env.server.deletes, 0);
|
|
env.t.useDialog(() => Promise.resolve('delete'));
|
|
await env.els.syncDelete.handlers.click();
|
|
await settle();
|
|
assert.strictEqual(env.server.deletes, 1);
|
|
assert.strictEqual(env.t.state.hold, true);
|
|
assert.strictEqual(env.els.syncMsg.textContent, 'Synced settings deleted from your account.');
|
|
assert.strictEqual(env.els.syncStatus.textContent, 'No settings saved in your account. Your next change starts a new copy.');
|
|
assert.strictEqual(env.ls.getItem('meshcore-favorites'), J(['a']));
|
|
});
|
|
|
|
test('dialog: escaped, first choice focused, Tab trapped, Escape and backdrop dismiss, focus returns', async () => {
|
|
const env = makeEnv({ enabled: false });
|
|
const prev = { focused: 0, focus() { this.focused++; } };
|
|
const open = (title) => {
|
|
env.doc.activeElement = prev;
|
|
const p = env.t.showDialog({ title, text: ['<b>t</b>'], choices: [{ id: 'a', label: 'A', primary: true }, { id: 'b', label: 'B' }] });
|
|
return { p, ov: env.doc.body.children[env.doc.body.children.length - 1] };
|
|
};
|
|
let { p, ov } = open('<img>');
|
|
assert(ov.innerHTML.indexOf('<img>') === -1 && ov.innerHTML.indexOf('<img>') !== -1);
|
|
assert(ov.innerHTML.indexOf('<b>') === -1);
|
|
assert(ov.innerHTML.indexOf('role="dialog"') !== -1 && ov.innerHTML.indexOf('aria-modal="true"') !== -1);
|
|
const [a, b] = ov.querySelectorAll();
|
|
assert.strictEqual(env.doc.activeElement, a);
|
|
const key = (k, shift) => {
|
|
let prevented = false;
|
|
ov.handlers.keydown({ key: k, shiftKey: !!shift, preventDefault() { prevented = true; }, stopPropagation() {} });
|
|
return prevented;
|
|
};
|
|
assert(key('Tab', true));
|
|
assert.strictEqual(env.doc.activeElement, b);
|
|
assert(key('Tab'));
|
|
assert.strictEqual(env.doc.activeElement, a);
|
|
key('Escape');
|
|
assert.strictEqual(await p, null);
|
|
assert(ov.removed);
|
|
assert.strictEqual(prev.focused, 1);
|
|
({ p, ov } = open('x'));
|
|
ov.handlers.click({ target: ov });
|
|
assert.strictEqual(await p, null);
|
|
({ p, ov } = open('x'));
|
|
ov.handlers.click({ target: ov.querySelectorAll()[1] });
|
|
assert.strictEqual(await p, 'b');
|
|
});
|
|
|
|
test('delete while retrying: the pending retry does not recreate the account copy', async () => {
|
|
const server = serverWith(1, KEYS);
|
|
const env = makeEnv({ server, local: synced(KEYS, 1) });
|
|
await env.timers.advance(0);
|
|
server.fail.PUT = ['network'];
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
await env.timers.advance(2000);
|
|
assert.strictEqual(env.t.state.status, 'retrying');
|
|
const puts = server.puts.length;
|
|
const r = await env.t.deleteRemote();
|
|
assert.strictEqual(r.ok, true);
|
|
await env.timers.advance(600000);
|
|
assert.strictEqual(server.puts.length, puts);
|
|
assert.strictEqual(env.t.state.hold, true);
|
|
assert.strictEqual(server.doc, null);
|
|
});
|
|
|
|
test('delete with a PUT in flight: the late answer does not undo the hold', async () => {
|
|
const server = serverWith(1, KEYS);
|
|
const env = makeEnv({ server, local: synced(KEYS, 1) });
|
|
await env.timers.advance(0);
|
|
const release = holdNext(env, 'PUT');
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
await env.timers.advance(2000); // PUT sent, answer held
|
|
const d = env.t.deleteRemote();
|
|
await release();
|
|
assert.strictEqual((await d).ok, true);
|
|
assert.strictEqual(env.t.state.hold, true, 'hold lost when the PUT answer landed');
|
|
assert.strictEqual(env.t.state.rev, 0);
|
|
await env.timers.advance(600000);
|
|
assert.strictEqual(env.t.state.hold, true);
|
|
assert.strictEqual(server.doc, null);
|
|
assert.strictEqual(server.deletes, 1);
|
|
assert.strictEqual(JSON.parse(env.ls.getItem('cs-settings-sync-base')).hold, true);
|
|
});
|
|
|
|
test('a failed delete puts unsynced changes back on the retry backoff', async () => {
|
|
const server = serverWith(1, KEYS);
|
|
const env = makeEnv({ server, local: synced(KEYS, 1) });
|
|
await env.timers.advance(0);
|
|
server.fail.PUT = ['network'];
|
|
env.ls.setItem('meshcore-time-window', '60');
|
|
await env.timers.advance(2000);
|
|
server.fail.DELETE = [{ status: 500, data: { error: 'boom' } }];
|
|
const r = await env.t.deleteRemote();
|
|
assert.strictEqual(r.status, 500);
|
|
assert.strictEqual(env.t.state.hold, false);
|
|
assert.strictEqual(env.t.state.status, 'retrying');
|
|
assert.strictEqual(env.timers.delays().length, 1, 'no retry scheduled');
|
|
await env.timers.advance(600000);
|
|
assert.strictEqual(server.doc.keys['meshcore-time-window'], '60');
|
|
assert.strictEqual(env.t.state.status, 'ok');
|
|
});
|
|
|
|
test('delete confirmation focuses Cancel; the dialog focuses opts.focus', async () => {
|
|
const env = makeEnv({ server: serverWith(1, KEYS), local: synced(KEYS, 1) });
|
|
await env.timers.advance(0);
|
|
env.api.mountSection(env.el('syncSection'));
|
|
let seen = null;
|
|
env.t.useDialog((opts) => { seen = plain(opts); return Promise.resolve('cancel'); });
|
|
await env.els.syncDelete.handlers.click();
|
|
assert.strictEqual(seen.focus, 'cancel');
|
|
const p = env.t.showDialog(Object.assign({}, seen, { title: 'x' }));
|
|
const ov = env.doc.body.children[env.doc.body.children.length - 1];
|
|
const btns = ov.querySelectorAll();
|
|
assert.strictEqual(btns[0].getAttribute(), 'delete');
|
|
assert.strictEqual(env.doc.activeElement, btns[1]);
|
|
ov.handlers.click({ target: ov });
|
|
assert.strictEqual(await p, null);
|
|
});
|
|
|
|
test('status texts', async () => {
|
|
const env = makeEnv({ server: serverWith(1, {}), local: synced({}, 1) });
|
|
await env.timers.advance(0);
|
|
const st = env.t.state;
|
|
st.status = 'retrying';
|
|
assert.strictEqual(env.t.statusText(), 'Not synced: retrying');
|
|
st.status = 'too-large'; st.tooLarge = ['cs-theme-overrides', 'meshcore-my-nodes'];
|
|
assert.strictEqual(env.t.statusText(), 'Not synced: your settings are larger than your account can hold. Largest: cs-theme-overrides, meshcore-my-nodes');
|
|
st.status = 'idle';
|
|
assert.strictEqual(env.t.statusText(), 'Not synced yet');
|
|
});
|
|
|
|
(async () => {
|
|
let passed = 0, failed = 0;
|
|
for (const t of tests) {
|
|
try { await t.fn(); passed++; console.log(' ok ' + t.name); }
|
|
catch (e) { failed++; console.log(' FAIL ' + t.name + ': ' + (e && e.stack || e)); }
|
|
}
|
|
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
|
process.exit(failed ? 1 : 0);
|
|
})();
|