/* 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: ['t'], 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(''); assert(ov.innerHTML.indexOf('') === -1 && ov.innerHTML.indexOf('<img>') !== -1); assert(ov.innerHTML.indexOf('') === -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); })();