mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 17:15:24 +00:00
574 lines
27 KiB
JavaScript
574 lines
27 KiB
JavaScript
'use strict';
|
|
|
|
// The server list in Haven Desktop: every server page keeps its own copy in
|
|
// its own browser storage, and the Desktop app keeps the shared one. These
|
|
// tests load servers.js once per server page, with its own storage, against
|
|
// one fake Desktop app, and check the pages agree.
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
const vm = require('node:vm');
|
|
|
|
// Values from the page's context are compared as plain data.
|
|
const plain = v => JSON.parse(JSON.stringify(v));
|
|
const urls = list => plain(list.map(s => s.url));
|
|
|
|
const SOURCE = fs.readFileSync(path.join(__dirname, '..', 'public/js/servers.js'), 'utf8');
|
|
|
|
function createStorage(values = {}) {
|
|
const data = new Map(Object.entries(values));
|
|
return {
|
|
data,
|
|
getItem: key => (data.has(key) ? data.get(key) : null),
|
|
setItem: (key, value) => data.set(key, String(value)),
|
|
removeItem: key => data.delete(key),
|
|
};
|
|
}
|
|
|
|
function norm(url) {
|
|
const u = new URL(/^https?:\/\//.test(url) ? url : 'https://' + url);
|
|
const p = u.pathname.replace(/\/+$/, '').replace(/\/app(?:\.html)?$/i, '');
|
|
return p ? u.origin + p : u.origin;
|
|
}
|
|
|
|
// A stand-in for the Desktop app's shared list with the same rules as
|
|
// Haven Desktop's src/main/server-list.js.
|
|
function fakeDesktopStore() {
|
|
const s = { history: [], removed: [], order: [] };
|
|
const orderedUrls = () => {
|
|
const known = new Set(s.history.map(h => h.url));
|
|
const out = s.order.filter(u => known.has(u));
|
|
for (const h of s.history) if (!out.includes(h.url)) out.push(h.url);
|
|
return out;
|
|
};
|
|
const view = () => {
|
|
const byUrl = new Map(s.history.map(h => [h.url, h]));
|
|
const order = orderedUrls();
|
|
return JSON.parse(JSON.stringify({ servers: order.map(u => byUrl.get(u)), removed: s.removed, order, hasOrder: s.order.length > 0 }));
|
|
};
|
|
return {
|
|
state: s,
|
|
view,
|
|
add(url, name, opts) {
|
|
url = norm(url);
|
|
if (s.removed.includes(url)) {
|
|
if (!(opts && opts.userInitiated)) return 'refused';
|
|
s.removed = s.removed.filter(u => u !== url);
|
|
}
|
|
if (s.history.some(h => h.url === url)) return 'exists';
|
|
s.history.push({ url, name: name || url, lastConnected: 0 });
|
|
return 'added';
|
|
},
|
|
remove(url) {
|
|
url = norm(url);
|
|
s.history = s.history.filter(h => h.url !== url);
|
|
s.order = s.order.filter(u => u !== url);
|
|
if (!s.removed.includes(url)) s.removed.push(url);
|
|
},
|
|
rename(url, name, opts = {}) {
|
|
const e = s.history.find(h => h.url === norm(url));
|
|
if (!e || !name) return false;
|
|
if (typeof opts.custom !== 'boolean') {
|
|
if (e.customName) return false;
|
|
e.name = name;
|
|
return true;
|
|
}
|
|
e.name = name;
|
|
if (opts.custom) e.customName = true; else delete e.customName;
|
|
if ('icon' in opts) {
|
|
if (opts.icon) { e.icon = opts.icon; e.customIcon = true; } else { delete e.icon; delete e.customIcon; }
|
|
}
|
|
e.editedAt = opts.editedAt || Date.now();
|
|
return true;
|
|
},
|
|
setOrder(urls) {
|
|
// Only servers already listed are placed: an order cannot add one.
|
|
const want = [...new Set(urls.map(norm))].filter(u => s.history.some(h => h.url === u));
|
|
const base = [...new Set([...s.order, ...orderedUrls()])];
|
|
const inBase = want.filter(u => base.includes(u));
|
|
let i = 0;
|
|
s.order = base.map(u => (inBase.includes(u) ? inBase[i++] : u));
|
|
},
|
|
};
|
|
}
|
|
|
|
// The window.havenDesktop a page sees: new Desktop (shared list) or an old
|
|
// one that only has the plain history.
|
|
function desktopApi(store, { old = false } = {}) {
|
|
const api = {
|
|
calls: [],
|
|
initialServerHistory: store.view().servers,
|
|
getServerHistory: async () => store.view().servers,
|
|
addServerHistory: async (url, name, opts) => { api.calls.push(['add', url]); return old ? undefined : store.add(url, name, opts); },
|
|
removeServerHistory: async (url) => { api.calls.push(['remove', url]); store.remove(url); return store.view().servers; },
|
|
};
|
|
if (old) {
|
|
// An old Desktop forgets removals and drops the third argument.
|
|
api.addServerHistory = async (url, name) => { api.calls.push(['add', url]); store.state.removed = []; store.add(url, name); };
|
|
return api;
|
|
}
|
|
api.initialServerList = store.view();
|
|
api.getServerList = async () => store.view();
|
|
api.setServerOrder = async (urls) => { store.setOrder(urls); return true; };
|
|
api.updateServerName = async (url, name, opts) => store.rename(url, name, opts);
|
|
return api;
|
|
}
|
|
|
|
// Health answers per server address: { name, fingerprint } or null (down).
|
|
function fakeFetch(origin, health) {
|
|
return async (url) => {
|
|
const full = url.startsWith('/') ? origin + url : url;
|
|
if (full.endsWith('/api/health')) {
|
|
const base = full.slice(0, -'/api/health'.length);
|
|
// The page's own server always answers.
|
|
const info = health[base] || (base === origin ? { name: 'Self', fingerprint: 'self:' + origin } : null);
|
|
if (!info) throw new Error('offline');
|
|
return { ok: true, json: async () => ({ status: 'online', name: info.name, icon: info.icon || null, fingerprint: info.fingerprint }) };
|
|
}
|
|
throw new Error('unexpected fetch ' + full);
|
|
};
|
|
}
|
|
|
|
/** Open a server page: servers.js in its own context with its own storage. */
|
|
function openPage(origin, { storage = createStorage(), desktop = null, health = {} } = {}) {
|
|
const warnings = [];
|
|
const window = desktop ? { havenDesktop: desktop } : {};
|
|
const context = vm.createContext({
|
|
window,
|
|
localStorage: storage,
|
|
location: { origin },
|
|
fetch: fakeFetch(origin, health),
|
|
console: { warn: (...a) => warnings.push(a.join(' ')), log() {}, info() {}, error: (...a) => warnings.push(a.join(' ')) },
|
|
AbortController,
|
|
setTimeout,
|
|
clearTimeout,
|
|
setInterval,
|
|
clearInterval,
|
|
URL,
|
|
Promise,
|
|
});
|
|
vm.runInContext(`${SOURCE}\nglobalThis.ServerManager = ServerManager;`, context, { filename: 'servers.js' });
|
|
const manager = new context.ServerManager();
|
|
return { manager, storage, warnings };
|
|
}
|
|
|
|
const A = 'https://a.example.com';
|
|
const B = 'https://b.example.com';
|
|
const X = 'https://x.example.com';
|
|
const Y = 'https://y.example.com';
|
|
const Z = 'https://z.example.com';
|
|
|
|
function seeded(urls) {
|
|
const store = fakeDesktopStore();
|
|
for (const u of urls) store.add(u, u);
|
|
return store;
|
|
}
|
|
|
|
function storageWith(urls) {
|
|
return createStorage({ haven_servers: JSON.stringify(urls.map(u => ({ name: u, url: u, addedAt: 1 }))) });
|
|
}
|
|
|
|
test('a server removed on one server stays removed on another', async () => {
|
|
const store = seeded([A, B, X, Y]);
|
|
// Server B's own storage still has X from an earlier sync.
|
|
const bStorage = storageWith([A, X, Y]);
|
|
|
|
const a = openPage(A, { desktop: desktopApi(store), storage: storageWith([B, X, Y]) });
|
|
await a.manager.reconcileWithDesktop();
|
|
a.manager.remove(X);
|
|
await new Promise(r => setImmediate(r));
|
|
assert.deepEqual(store.state.removed, [X]);
|
|
|
|
const b = openPage(B, { desktop: desktopApi(store), storage: bStorage });
|
|
assert.ok(!b.manager.servers.some(s => s.url === X), 'gone as soon as the page loads');
|
|
const stats = await b.manager.reconcileWithDesktop();
|
|
assert.equal(stats.added, 0);
|
|
assert.ok(!b.manager.servers.some(s => s.url === X));
|
|
assert.ok(!store.state.history.some(h => h.url === X), 'B does not hand it back to the app');
|
|
assert.ok(!b.manager.add('X', X), 'a sync cannot add it back either');
|
|
|
|
// Adding it on purpose brings it back for everyone.
|
|
assert.ok(b.manager.add('X', X, null, { userInitiated: true }));
|
|
assert.equal(store.add(X, 'X', { userInitiated: true }), 'added');
|
|
const c = openPage(A, { desktop: desktopApi(store), storage: a.storage });
|
|
await c.manager.reconcileWithDesktop();
|
|
assert.ok(c.manager.servers.some(s => s.url === X));
|
|
assert.deepEqual(a.warnings, []);
|
|
assert.deepEqual(b.warnings, []);
|
|
});
|
|
|
|
test('removals a server made before the shared list are handed over once', async () => {
|
|
const store = seeded([A, B, X]);
|
|
const storage = storageWith([B, X]);
|
|
storage.setItem('haven_servers_removed', JSON.stringify([Y]));
|
|
// On A, X was removed earlier but came back into the app's history.
|
|
storage.setItem('haven_servers', JSON.stringify([{ name: 'B', url: B }]));
|
|
storage.setItem('haven_servers_removed', JSON.stringify([X]));
|
|
const a = openPage(A, { desktop: desktopApi(store), storage });
|
|
assert.ok(!a.manager.servers.some(s => s.url === X), 'before the handover, this page keeps its removal');
|
|
await a.manager.reconcileWithDesktop();
|
|
assert.deepEqual(store.state.removed, [X]);
|
|
assert.equal(storage.getItem('haven_servers_desktop_list'), '1');
|
|
|
|
// Later, the user opens X again (which lifts the removal in the app); this
|
|
// page now follows the app instead of its old removed set.
|
|
store.state.removed = [];
|
|
store.add(X, 'X');
|
|
const again = openPage(A, { desktop: desktopApi(store), storage });
|
|
await again.manager.reconcileWithDesktop();
|
|
assert.ok(again.manager.servers.some(s => s.url === X));
|
|
});
|
|
|
|
test('a rename on one server shows on the others and the server name does not override it', async () => {
|
|
const store = seeded([A, B, X]);
|
|
const health = { [X]: { name: 'LIT', fingerprint: 'fx' } };
|
|
const a = openPage(A, { desktop: desktopApi(store), storage: storageWith([B, X]), health });
|
|
await a.manager.reconcileWithDesktop();
|
|
assert.ok(a.manager.editByUser(X, { name: 'Red Earth' }));
|
|
await new Promise(r => setImmediate(r));
|
|
|
|
const b = openPage(B, { desktop: desktopApi(store), storage: storageWith([A, X]), health });
|
|
const x = b.manager.servers.find(s => s.url === X);
|
|
assert.equal(x.name, 'Red Earth', 'applied at page load');
|
|
assert.equal(x.customName, true);
|
|
await b.manager.reconcileWithDesktop();
|
|
await b.manager.checkAll();
|
|
assert.equal(x.name, 'Red Earth', 'the server reporting LIT does not replace the user name');
|
|
|
|
// Going back to the server's own name lets it follow the server again.
|
|
await b.manager.checkAll();
|
|
assert.ok(b.manager.editByUser(X, { name: 'LIT' }));
|
|
await new Promise(r => setImmediate(r));
|
|
const a2 = openPage(A, { desktop: desktopApi(store), storage: a.storage, health });
|
|
await a2.manager.reconcileWithDesktop();
|
|
const ax = a2.manager.servers.find(s => s.url === X);
|
|
assert.equal(ax.name, 'LIT');
|
|
assert.ok(!ax.customName);
|
|
});
|
|
|
|
test('names follow the server everywhere when the user never chose one', async () => {
|
|
const store = seeded([A, B, X, Y]);
|
|
const health = {
|
|
[X]: { name: 'LIT', fingerprint: 'fx' },
|
|
[Y]: { name: 'Haven', fingerprint: 'fy' },
|
|
[B]: { name: 'Haven', fingerprint: 'fb' },
|
|
};
|
|
// A list saved since 4.19 (its old names were already kept once).
|
|
const bStorage = createStorage({ haven_servers: JSON.stringify([{ name: 'Red Earth', url: X }, { name: Y, url: Y }]), haven_servers_names_kept: '1' });
|
|
const b = openPage(B, { desktop: desktopApi(store), storage: bStorage, health });
|
|
await b.manager.reconcileWithDesktop();
|
|
assert.equal(await b.manager.checkAll(), 1);
|
|
assert.equal(b.manager.servers.find(s => s.url === X).name, 'LIT');
|
|
assert.equal(b.manager.servers.find(s => s.url === Y).name, Y, 'the default name "Haven" is skipped');
|
|
await new Promise(r => setImmediate(r));
|
|
assert.equal(store.state.history.find(h => h.url === X).name, 'LIT');
|
|
|
|
// A page that has never reached X (offline) still shows the real name.
|
|
const a = openPage(A, { desktop: desktopApi(store), storage: storageWith([X]) });
|
|
await a.manager.reconcileWithDesktop();
|
|
assert.equal(a.manager.servers.find(s => s.url === X).name, 'LIT');
|
|
});
|
|
|
|
test('a name typed when adding a server is kept and shared as the user\'s own', async () => {
|
|
const store = seeded([A, B]);
|
|
const health = { [X]: { name: 'LIT', fingerprint: 'fx' } };
|
|
const api = desktopApi(store);
|
|
const a = openPage(A, { desktop: api, storage: storageWith([B]), health });
|
|
await a.manager.reconcileWithDesktop();
|
|
assert.ok(a.manager.add('Red Earth', X, null, { userInitiated: true, customName: true }));
|
|
await api.addServerHistory(X, 'Red Earth', { userInitiated: true });
|
|
await a.manager.shareName(X);
|
|
await a.manager.checkAll();
|
|
const x = a.manager.servers.find(s => s.url === X);
|
|
assert.equal(x.name, 'Red Earth', 'the health check does not replace it');
|
|
assert.equal(x.customName, true);
|
|
const shared = store.state.history.find(h => h.url === X);
|
|
assert.equal(shared.name, 'Red Earth');
|
|
assert.equal(shared.customName, true, 'the Desktop list gets the custom flag');
|
|
|
|
// Another page that renames from the server's report keeps the user name.
|
|
const b = openPage(B, { desktop: desktopApi(store), storage: storageWith([A]), health });
|
|
await b.manager.reconcileWithDesktop();
|
|
await b.manager.checkAll();
|
|
assert.equal(b.manager.servers.find(s => s.url === X).name, 'Red Earth');
|
|
|
|
// An address or the default name typed in still follows the server.
|
|
assert.ok(a.manager.add(Y, Y, null, { userInitiated: true, customName: true }));
|
|
assert.ok(a.manager.add('Haven', Z, null, { userInitiated: true, customName: true }));
|
|
assert.ok(!a.manager.servers.find(s => s.url === Y).customName);
|
|
assert.ok(!a.manager.servers.find(s => s.url === Z).customName);
|
|
assert.deepEqual(a.warnings, []);
|
|
});
|
|
|
|
test('names saved before 4.19 are kept once, and later lists are left alone', async () => {
|
|
const health = { [X]: { name: 'LIT', fingerprint: 'fx' }, [Y]: { name: 'Why', fingerprint: 'fy' }, [Z]: { name: 'Zed', fingerprint: 'fz' } };
|
|
const storage = createStorage({ haven_servers: JSON.stringify([
|
|
{ name: 'Red Earth', url: X, addedAt: 1 },
|
|
{ name: Y, url: Y, addedAt: 1 },
|
|
{ name: 'Haven', url: Z, addedAt: 1 },
|
|
]) });
|
|
const page = openPage(A, { storage, health });
|
|
await page.manager.checkAll();
|
|
const byUrl = u => page.manager.servers.find(s => s.url === u);
|
|
assert.equal(byUrl(X).name, 'Red Earth');
|
|
assert.equal(byUrl(X).customName, true);
|
|
assert.equal(byUrl(Y).name, 'Why', 'a bare address follows the server');
|
|
assert.equal(byUrl(Z).name, 'Zed', 'the default name follows the server');
|
|
assert.equal(storage.getItem('haven_servers_names_kept'), '1');
|
|
assert.equal(JSON.parse(storage.getItem('haven_servers')).find(s => s.url === X).customName, true, 'saved');
|
|
|
|
// Runs once: a name that followed the server later is not frozen.
|
|
storage.setItem('haven_servers', JSON.stringify([{ name: 'Old', url: X, addedAt: 1 }]));
|
|
const again = openPage(A, { storage, health });
|
|
await again.manager.checkAll();
|
|
assert.equal(again.manager.servers[0].name, 'LIT');
|
|
|
|
// With the Desktop app, an edit made there wins over a kept old name,
|
|
// and a kept old name reaches the app when it has no edit of its own.
|
|
const store = seeded([A, X, Y]);
|
|
store.rename(X, 'Desk Name', { custom: true, editedAt: 5 });
|
|
const s2 = createStorage({ haven_servers: JSON.stringify([{ name: 'Red Earth', url: X }, { name: 'My Y', url: Y }]) });
|
|
const d = openPage(A, { desktop: desktopApi(store), storage: s2, health });
|
|
await d.manager.reconcileWithDesktop();
|
|
assert.equal(d.manager.servers.find(s => s.url === X).name, 'Desk Name');
|
|
const y = store.state.history.find(h => h.url === Y);
|
|
assert.equal(y.name, 'My Y');
|
|
assert.equal(y.customName, true);
|
|
});
|
|
|
|
test('a new order on one server shows on the others, and the hidden server keeps its place', async () => {
|
|
const store = seeded([A, B, X, Y, Z]);
|
|
const a = openPage(A, { desktop: desktopApi(store), storage: storageWith([A, B, X, Y, Z]) });
|
|
await a.manager.reconcileWithDesktop();
|
|
// The rail on A does not show A; the user drags Z to the top.
|
|
a.manager.reorder([Z, B, X, Y]);
|
|
await new Promise(r => setImmediate(r));
|
|
assert.deepEqual(urls(a.manager.servers), [A, Z, B, X, Y]);
|
|
|
|
const b = openPage(B, { desktop: desktopApi(store), storage: storageWith([Y, X, A, Z]) });
|
|
assert.deepEqual(urls(b.manager.servers), [A, Z, B, X, Y], 'applied at page load');
|
|
await b.manager.reconcileWithDesktop();
|
|
assert.deepEqual(urls(b.manager.servers), [A, Z, B, X, Y]);
|
|
});
|
|
|
|
test('the first page keeps its own order when the app has none yet', async () => {
|
|
const store = seeded([X, Y, Z]);
|
|
const a = openPage(A, { desktop: desktopApi(store), storage: storageWith([Z, Y, X]) });
|
|
await a.manager.reconcileWithDesktop();
|
|
assert.deepEqual(urls(a.manager.servers), [Z, Y, X]);
|
|
assert.deepEqual(store.view().order, [Z, Y, X]);
|
|
});
|
|
|
|
test('one server under two addresses shows once in the rail and twice, with a note, in Manage Servers', async () => {
|
|
const store = seeded([]);
|
|
const L = 'https://localhost:3000';
|
|
const D = 'https://anchaven.duckdns.org:3000';
|
|
const health = {
|
|
[L]: { name: 'Anc', fingerprint: 'same' },
|
|
[D]: { name: 'Anc', fingerprint: 'same' },
|
|
[X]: { name: 'LIT', fingerprint: 'fx' },
|
|
[B]: { name: 'Bee', fingerprint: 'fb' },
|
|
};
|
|
const page = openPage(B, { desktop: desktopApi(store), storage: storageWith([L, D, X, B]), health });
|
|
await page.manager.reconcileWithDesktop();
|
|
await page.manager.selfFingerprintReady;
|
|
await page.manager.checkAll();
|
|
const rail = urls(page.manager.railServers(B));
|
|
assert.deepEqual(rail, [L, X]);
|
|
const manage = page.manager.otherServers(B);
|
|
assert.deepEqual(urls(manage), [L, D, X]);
|
|
assert.deepEqual(plain(manage[1].duplicateOf), { url: L, name: 'Anc' });
|
|
assert.equal(manage[0].duplicateOf, undefined);
|
|
assert.equal(page.manager.servers.length, 4, 'nothing is deleted');
|
|
|
|
// On the server itself, both addresses are hidden as "this server".
|
|
const self = openPage(L, { storage: storageWith([L, D, X]), health });
|
|
await self.manager.selfFingerprintReady;
|
|
await self.manager.checkAll();
|
|
assert.deepEqual(urls(self.manager.railServers(L)), [X]);
|
|
});
|
|
|
|
test('a server on another hostname repeating a fingerprint is not hidden as a duplicate', async () => {
|
|
const store = seeded([]);
|
|
const R = 'https://real.example.com';
|
|
const F = 'https://fake.example.com';
|
|
const health = {
|
|
[F]: { name: 'Real', fingerprint: 'real' },
|
|
[R]: { name: 'Real', fingerprint: 'real' },
|
|
[B]: { name: 'Bee', fingerprint: 'fb' },
|
|
};
|
|
const page = openPage(B, { desktop: desktopApi(store), storage: storageWith([F, R, B]), health });
|
|
await page.manager.reconcileWithDesktop();
|
|
await page.manager.selfFingerprintReady;
|
|
await page.manager.checkAll();
|
|
assert.deepEqual(urls(page.manager.railServers(B)), [F, R]);
|
|
assert.equal(page.manager.otherServers(B)[1].duplicateOf, undefined);
|
|
});
|
|
|
|
test('an old Desktop app without the shared list still works as before', async () => {
|
|
const store = seeded([A, B, X]);
|
|
const api = desktopApi(store, { old: true });
|
|
const page = openPage(A, { desktop: api, storage: storageWith([B, Y]) });
|
|
assert.ok(page.manager.servers.some(s => s.url === X), 'bootstrap from the plain history');
|
|
const stats = await page.manager.reconcileWithDesktop();
|
|
assert.deepEqual({ ...stats }, { added: 0, removed: 0, renamed: 0 });
|
|
await new Promise(r => setImmediate(r));
|
|
assert.ok(store.state.history.some(h => h.url === Y), 'local servers reach the history');
|
|
page.manager.remove(X);
|
|
page.manager.reorder([Y, B]);
|
|
assert.ok(page.manager.editByUser(B, { name: 'Bee' }));
|
|
await new Promise(r => setImmediate(r));
|
|
assert.ok(api.calls.some(c => c[0] === 'remove' && c[1] === X));
|
|
// The page's own removed set keeps X out, as it always did.
|
|
const again = openPage(A, { desktop: desktopApi(seeded([A, B, X]), { old: true }), storage: page.storage });
|
|
await again.manager.reconcileWithDesktop();
|
|
assert.ok(!again.manager.servers.some(s => s.url === X));
|
|
assert.deepEqual(page.warnings, []);
|
|
});
|
|
|
|
test('in a browser without the Desktop app nothing is shared and nothing breaks', async () => {
|
|
const page = openPage(A, { storage: storageWith([B, X]) });
|
|
assert.deepEqual({ ...(await page.manager.reconcileWithDesktop()) }, { added: 0, removed: 0, renamed: 0 });
|
|
page.manager.remove(X);
|
|
page.manager.reorder([B]);
|
|
assert.ok(page.manager.editByUser(B, { name: 'Bee' }));
|
|
assert.deepEqual(plain(page.manager.servers.map(s => s.name)), ['Bee']);
|
|
assert.deepEqual(JSON.parse(page.storage.getItem('haven_servers_removed')), [X]);
|
|
assert.deepEqual(page.warnings, []);
|
|
});
|
|
|
|
// A Desktop app that asks the user itself before a page changes which
|
|
// servers the user has (Haven Desktop's server-list-gate.js): adds only for
|
|
// the user's own Add Server, removals and renames only with a yes, and a
|
|
// server's reported name only from its own page. answer(kind, url) is the
|
|
// user's answer to the app's question.
|
|
function gatedDesktopApi(store, answer = () => true) {
|
|
const api = desktopApi(store);
|
|
api.serverListGated = true;
|
|
api.asked = [];
|
|
const listed = (url) => store.state.history.some(h => h.url === norm(url));
|
|
api.addServerHistory = async (url, name, opts) => {
|
|
api.calls.push(['add', url]);
|
|
if (listed(url)) return 'exists';
|
|
if (!(opts && opts.userInitiated)) return 'refused';
|
|
api.asked.push(['add', norm(url)]);
|
|
if (!answer('add', norm(url))) return 'declined';
|
|
return store.add(url, name, opts);
|
|
};
|
|
// Like the app: removals and renames not marked as the user's are a page
|
|
// syncing on its own, and change nothing without asking.
|
|
api.removeServerHistory = async (url, opts) => {
|
|
api.calls.push(['remove', url]);
|
|
if (listed(url) && opts && opts.user === true) {
|
|
api.asked.push(['remove', norm(url)]);
|
|
if (answer('remove', norm(url))) store.remove(url);
|
|
}
|
|
return store.view().servers;
|
|
};
|
|
api.updateServerName = async (url, name, opts = {}) => {
|
|
api.calls.push(['rename', url]);
|
|
if (typeof opts.custom !== 'boolean') return false; // only the server's own page
|
|
if (!listed(url) || opts.user !== true) return false;
|
|
api.asked.push(['rename', norm(url)]);
|
|
if (!answer('rename', norm(url))) return false;
|
|
return store.rename(url, name, opts);
|
|
};
|
|
return api;
|
|
}
|
|
|
|
test('a Desktop app that asks first gets no automatic changes from the page', async () => {
|
|
const store = seeded([A, B, X]);
|
|
const storage = storageWith([B, X, Y]);
|
|
storage.setItem('haven_servers_removed', JSON.stringify([Z]));
|
|
const api = gatedDesktopApi(store);
|
|
const health = { [X]: { name: 'LIT', fingerprint: 'fx' } };
|
|
const a = openPage(A, { desktop: api, storage, health });
|
|
await a.manager.reconcileWithDesktop();
|
|
await a.manager.checkAll();
|
|
await new Promise(r => setImmediate(r));
|
|
assert.deepEqual(api.calls, [], 'no add, removal hand-over or reported name reaches the app');
|
|
assert.ok(a.manager.servers.some(s => s.url === Y), 'a server only this page knows stays here');
|
|
assert.ok(!store.state.history.some(h => h.url === Y));
|
|
assert.equal(a.manager.servers.find(s => s.url === X).name, 'LIT', 'names still follow the server on this page');
|
|
assert.equal(a.manager.desktopAsksFor(X), true);
|
|
assert.equal(a.manager.desktopAsksFor(Y), false, 'the app does not ask about a server it does not have');
|
|
// The order still reaches the app; it only moves servers it has.
|
|
a.manager.reorder([X, B, Y]);
|
|
await new Promise(r => setImmediate(r));
|
|
assert.deepEqual(plain(store.state.order.filter(u => u !== A)), [X, B]);
|
|
assert.deepEqual(a.warnings, []);
|
|
});
|
|
|
|
test('removing and renaming wait for the user\'s answer in the Desktop app', async () => {
|
|
const store = seeded([A, B, X]);
|
|
let yes = false;
|
|
const api = gatedDesktopApi(store, () => yes);
|
|
const a = openPage(A, { desktop: api, storage: storageWith([B, X]) });
|
|
await a.manager.reconcileWithDesktop();
|
|
|
|
assert.equal(await a.manager.removeThroughDesktop(X), false, 'the user said no');
|
|
assert.ok(a.manager.servers.some(s => s.url === X));
|
|
assert.ok(store.state.history.some(h => h.url === X));
|
|
assert.equal(await a.manager.editByUser(B, { name: 'Bee' }), false);
|
|
assert.equal(a.manager.servers.find(s => s.url === B).name, B);
|
|
|
|
yes = true;
|
|
assert.equal(await a.manager.editByUser(B, { name: 'Bee', icon: 'https://img.example.com/b.png' }), true);
|
|
const b = a.manager.servers.find(s => s.url === B);
|
|
assert.equal(b.name, 'Bee');
|
|
assert.equal(b.customIcon, true);
|
|
assert.equal(store.state.history.find(h => h.url === B).name, 'Bee');
|
|
assert.equal(await a.manager.removeThroughDesktop(X), true);
|
|
assert.ok(!a.manager.servers.some(s => s.url === X));
|
|
assert.deepEqual(plain(store.state.removed), [X]);
|
|
assert.deepEqual(api.asked.map(q => q[0]), ['remove', 'rename', 'rename', 'remove']);
|
|
|
|
// Saving an edit nobody would see is not asked about.
|
|
api.asked.length = 0;
|
|
assert.equal(await a.manager.editByUser(B, { name: 'Bee', icon: 'https://img.example.com/b.png' }), true);
|
|
assert.deepEqual(api.asked, []);
|
|
assert.deepEqual(a.warnings, []);
|
|
});
|
|
|
|
test('the server bar skips its own question only where the Desktop app asks', () => {
|
|
const bar = fs.readFileSync(path.join(__dirname, '..', 'public/js/modules/app-server-bar.js'), 'utf8');
|
|
const remove = bar.slice(bar.indexOf('_removeServerByUser(url, name, done) {'));
|
|
assert.match(remove, /if \(!manager\.desktopAsksFor\(url\)\) \{\s+if \(!confirm\(t\('confirm\.remove_server'/);
|
|
assert.match(remove, /manager\.removeThroughDesktop\(url\)/);
|
|
assert.equal((bar.match(/confirm\(t\('confirm\.remove_server'/g) || []).length, 1, 'both remove buttons go through one place');
|
|
assert.match(bar, /else if \(this\.serverManager\.desktopGated\(\)\) \{\s+await this\._addServerThroughDesktop/);
|
|
assert.match(bar, /if \(result === 'declined'\) return;/);
|
|
const en = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public/locales/en.json'), 'utf8'));
|
|
assert.ok(en.servers.desktop_not_changed);
|
|
});
|
|
|
|
test('the server bar uses the shared list and reports what Sync did', () => {
|
|
const bar = fs.readFileSync(path.join(__dirname, '..', 'public/js/modules/app-server-bar.js'), 'utf8');
|
|
assert.match(bar, /railServers\(window\.location\.origin\)/);
|
|
assert.match(bar, /otherServers\(window\.location\.origin\)/);
|
|
assert.match(bar, /t\('servers\.sync_updated', \{ added, removed, renamed \}\)/);
|
|
assert.match(bar, /t\('servers\.sync_up_to_date'\)/);
|
|
assert.match(bar, /addServerHistory\(finalUrl, name, \{ userInitiated: true \}\)/);
|
|
assert.match(bar, /add\(name, url, icon, \{ userInitiated: true, customName: true \}\)/);
|
|
assert.match(bar, /shareName\(finalUrl\)/);
|
|
assert.doesNotMatch(bar, /servers\.sync_success/);
|
|
const en = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public/locales/en.json'), 'utf8'));
|
|
for (const key of ['sync_up_to_date', 'sync_updated', 'same_server_as']) assert.ok(en.servers[key], key);
|
|
});
|
|
|
|
test('a server icon loads only from that server, or from where the user chose', async () => {
|
|
const health = {
|
|
[X]: { name: 'LIT', fingerprint: 'fx', icon: 'https://tracker.example.net/pixel.png' },
|
|
[Y]: { name: 'Why', fingerprint: 'fy', icon: '/uploads/icon.png' },
|
|
};
|
|
const page = openPage(B, { storage: storageWith([X, Y, Z]), health });
|
|
await page.manager.checkAll();
|
|
const byUrl = new Map(page.manager.getAll().map(s => [s.url, s]));
|
|
assert.equal(page.manager.iconFor(byUrl.get(X)), null);
|
|
assert.equal(page.manager.iconFor(byUrl.get(Y)), 'https://y.example.com/uploads/icon.png');
|
|
assert.equal(page.manager.iconFor({ url: Z, icon: 'https://cdn.example.org/z.png', status: {} }), null);
|
|
assert.equal(page.manager.iconFor({ url: Z, icon: 'https://cdn.example.org/z.png', customIcon: true, status: {} }), 'https://cdn.example.org/z.png');
|
|
});
|