Files
small-packages/luci-theme-footstrap/tests/playground.test.mjs
T
2026-09-20 01:22:33 +08:00

532 lines
25 KiB
JavaScript

/* tools/playground/lib.mjs — the pure half of the record-and-replay pipeline. capture.mjs and
* verify.mjs both need a live owlab stand (T2); this is what proves the deterministic half without
* one: the recording key a request and a reply agree on, the rewrites build.mjs applies to a
* captured document, and the overlay/menu edits it makes on top of the recorded JSON. */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
stableStringify, ubusKey, requestKey, splitBatch,
rewriteBase, rewriteEnv, scrubTokens, scrubHost, rewriteHostname, rewriteHostnameInData,
rewriteLiteral, stripBase, jsonForScript, applyOverlay, pruneMenu, parseLsLines, TOKEN_STUB,
scrubDataDeep, findSecrets, guardNoSecrets, resolveUnderRoot, isSafeReturn,
missingOverlayKeys, waitForQuiet, drainReads, createActivityTracker, createGenerationGate,
describePendingRequest, describePendingRequests,
} from '../tools/playground/lib.mjs';
test('stableStringify sorts object keys but keeps array order', () => {
assert.equal(stableStringify({ b: 1, a: 2 }), '{"a":2,"b":1}');
assert.equal(stableStringify(['b', 'a']), '["b","a"]');
assert.equal(stableStringify({ b: [1, { d: 1, c: 2 }], a: 1 }), '{"a":1,"b":[1,{"c":2,"d":1}]}');
});
test('ubusKey is stable under argument key order, distinct under value', () => {
assert.equal(ubusKey('system', 'board', { x: 1, y: 2 }), ubusKey('system', 'board', { y: 2, x: 1 }));
assert.notEqual(ubusKey('system', 'board', { x: 1 }), ubusKey('system', 'board', { x: 2 }));
assert.equal(ubusKey('system', 'board'), 'system.board({})');
});
test('requestKey: a `call` entry keys on object.method(args), sid and id play no part', () => {
const a = { jsonrpc: '2.0', id: 1, method: 'call', params: ['sid-one', 'uci', 'get', { config: 'system' }] };
const b = { jsonrpc: '2.0', id: 99, method: 'call', params: ['sid-two', 'uci', 'get', { config: 'system' }] };
assert.equal(requestKey(a), requestKey(b));
assert.equal(requestKey(a), 'uci.get({"config":"system"})');
});
test('requestKey: the boot `list` probe has no object.method pair', () => {
assert.equal(requestKey({ jsonrpc: '2.0', id: 'init', method: 'list', params: undefined }), 'list()');
});
test('splitBatch: a single request and a batched array both split by position, in order', () => {
const single = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'call', params: ['s', 'system', 'board', {}] });
const one = splitBatch(single);
assert.equal(one.length, 1);
assert.equal(one[0].key, 'system.board({})');
const batch = JSON.stringify([
{ jsonrpc: '2.0', id: 1, method: 'call', params: ['s', 'system', 'board', {}] },
{ jsonrpc: '2.0', id: 2, method: 'call', params: ['s', 'uci', 'get', { config: 'network' }] },
]);
const two = splitBatch(batch);
assert.equal(two.length, 2);
assert.deepEqual(two.map((e) => e.key), ['system.board({})', 'uci.get({"config":"network"})']);
});
test('rewriteBase: only a quoted /cgi-bin/ or /luci-static/ prefix gets BASE spliced in', () => {
const html = `<a href="/cgi-bin/luci/admin/status/overview">x</a><link href='/luci-static/resources/luci.js'>`;
const out = rewriteBase(html, '/luci-theme-footstrap/playground');
assert.equal(out, `<a href="/luci-theme-footstrap/playground/cgi-bin/luci/admin/status/overview">x</a>`
+ `<link href='/luci-theme-footstrap/playground/luci-static/resources/luci.js'>`);
});
test('rewriteBase leaves the escaped \\/cgi-bin\\/ form (the env block) untouched', () => {
const html = `L = new LuCI({"scriptname":"\\/cgi-bin\\/luci"});`;
assert.equal(rewriteBase(html, '/base'), html);
});
test('rewriteEnv rewrites scriptname/resource/media and points ubuspath at BASE/ubus/', () => {
const env = {
media: '/luci-static/bootstrap/footstrap-media.css',
resource: '/luci-static/resources',
scriptname: '/cgi-bin/luci',
pathinfo: '',
ubuspath: '/ubus/',
sessionid: 'a'.repeat(32),
};
const escaped = JSON.stringify(env).replace(/\//g, '\\/');
const html = `<script>L = new LuCI(${escaped});</script>`;
const out = rewriteEnv(html, '/luci-theme-footstrap/playground');
const m = out.match(/new LuCI\((\{.*?\})\);/s);
const rewritten = JSON.parse(m[1].replace(/\\\//g, '/'));
assert.equal(rewritten.scriptname, '/luci-theme-footstrap/playground/cgi-bin/luci');
assert.equal(rewritten.resource, '/luci-theme-footstrap/playground/luci-static/resources');
assert.equal(rewritten.media, '/luci-theme-footstrap/playground/luci-static/bootstrap/footstrap-media.css');
assert.equal(rewritten.ubuspath, '/luci-theme-footstrap/playground/ubus/');
/* untouched fields survive the round trip */
assert.equal(rewritten.pathinfo, '');
});
test('scrubTokens replaces every 32-hex literal, wherever it sits', () => {
const sid = 'deadbeef'.repeat(4);
const html = `L=new LuCI({"sessionid":"${sid}","token":"${sid}"}); confirm(true,0,"${sid}");`;
const out = scrubTokens(html);
assert.ok(!out.includes(sid));
assert.equal((out.match(new RegExp(TOKEN_STUB, 'g')) || []).length, 3);
});
test('scrubHost drops the stand host:port, leaves everything else', () => {
assert.equal(scrubHost('http://localhost:8341/cgi-bin/luci and localhost:8341 again', 'localhost:8341'),
'http://playground.invalid/cgi-bin/luci and playground.invalid again');
assert.equal(scrubHost('unchanged', ''), 'unchanged');
});
test('rewriteLiteral swaps every occurrence, deletes on an empty `to`, no-ops on an empty `from` or an unset/unchanged `to`', () => {
const html = '<div>keep this</div><p>No password set!</p><p>No password set!</p>';
assert.equal(rewriteLiteral(html, '<p>No password set!</p>', ''), '<div>keep this</div>');
assert.equal(rewriteLiteral(html, 'No password set!', 'root has a password'),
'<div>keep this</div><p>root has a password</p><p>root has a password</p>');
assert.equal(rewriteLiteral(html, '', 'x'), html);
assert.equal(rewriteLiteral(html, 'No password set!', undefined), html);
assert.equal(rewriteLiteral(html, 'No password set!', 'No password set!'), html);
});
test('rewriteHostname swaps every literal occurrence, no-ops on an empty or unchanged pair', () => {
const html = `<title>owrt2512 | Overview</title><h1>owrt2512</h1>`;
assert.equal(rewriteHostname(html, 'owrt2512', 'footstrap-playground'),
`<title>footstrap-playground | Overview</title><h1>footstrap-playground</h1>`);
assert.equal(rewriteHostname(html, '', 'x'), html);
assert.equal(rewriteHostname(html, 'owrt2512', 'owrt2512'), html);
});
test('rewriteHostnameInData swaps every occurrence recursively, no-ops on an empty or unchanged pair', () => {
const rpc = {
'system.board({})': { result: [ 0, { hostname: 'owrt2512', model: 'x' } ] },
'uci.get({"config":"system"})': { result: [ 0, { values: { '@system[0]': { hostname: 'owrt2512' } } } ] },
'network.getHostHints({})': { result: [ 0, { '00:11:22': { name: 'owrt2512' } } ] },
list: [ 'owrt2512', 1 ],
};
const out = rewriteHostnameInData(rpc, 'owrt2512', 'footstrap-playground');
assert.equal(out['system.board({})'].result[1].hostname, 'footstrap-playground');
assert.equal(out['uci.get({"config":"system"})'].result[1].values['@system[0]'].hostname, 'footstrap-playground');
assert.equal(out['network.getHostHints({})'].result[1]['00:11:22'].name, 'footstrap-playground');
assert.deepEqual(out.list, [ 'footstrap-playground', 1 ]);
/* the recording itself is never mutated */
assert.equal(rpc['system.board({})'].result[1].hostname, 'owrt2512');
assert.equal(rewriteHostnameInData(rpc, '', 'x'), rpc);
assert.equal(rewriteHostnameInData(rpc, 'owrt2512', 'owrt2512'), rpc);
});
test('parseLsLines splits `ls -1` stdout into filenames, drops the trailing blank line and blank input', () => {
assert.deepEqual(parseLsLines('wifi.svg\nwifi_disabled.svg\nsignal-000-000.svg\n'),
[ 'wifi.svg', 'wifi_disabled.svg', 'signal-000-000.svg' ]);
assert.deepEqual(parseLsLines(''), []);
assert.deepEqual(parseLsLines('\n\n'), []);
});
test('stripBase removes BASE from a string anywhere it sits, recurses into arrays/objects, no-ops without one', () => {
const base = '/luci-theme-footstrap/playground';
assert.equal(stripBase(`/www${base}/luci-static/resources/preload`, base), '/www/luci-static/resources/preload');
assert.deepEqual(stripBase({ path: `${base}/x`, list: [ `${base}/y`, 1 ] }, base), { path: '/x', list: [ '/y', 1 ] });
assert.equal(stripBase('unchanged', ''), 'unchanged');
});
test('jsonForScript escapes every `<` so an inlined value cannot close the enclosing <script> early', () => {
const out = jsonForScript({ evil: '</script><script>alert(1)</script><!--' });
assert.ok(!out.includes('</script'));
assert.ok(!out.includes('<!--'));
assert.deepEqual(JSON.parse(out), { evil: '</script><script>alert(1)</script><!--' });
});
test('applyOverlay: a nested merge patches one key deep without dropping its siblings', () => {
const rpc = {
'luci-rpc.getWirelessDevices({})': { jsonrpc: '2.0', id: 1, result: [0, {
radio0: { up: false, retry_setup_failed: true, config: { band: '2g' }, interfaces: [ 'x' ] },
}] },
};
const out = applyOverlay(rpc, {
'luci-rpc.getWirelessDevices({})': { merge: { radio0: { up: true, retry_setup_failed: false } } },
});
const radio0 = out['luci-rpc.getWirelessDevices({})'].result[1].radio0;
assert.equal(radio0.up, true);
assert.equal(radio0.retry_setup_failed, false);
assert.deepEqual(radio0.config, { band: '2g' });
assert.deepEqual(radio0.interfaces, [ 'x' ]);
});
test('pruneMenu keeps a leaf on a tree rooted at admin (action_menu\'s real shape), admin included', () => {
const menu = { children: { admin: { children: {
status: { title: 'Status', children: { overview: { title: 'Overview' } } },
network: { title: 'Network', children: { network: { title: 'Interfaces' } } },
} } } };
const out = pruneMenu(menu, [ 'admin/status/overview' ]);
assert.deepEqual(Object.keys(out.children), [ 'admin' ]);
assert.deepEqual(Object.keys(out.children.admin.children), [ 'status' ]);
assert.deepEqual(Object.keys(out.children.admin.children.status.children), [ 'overview' ]);
});
test('applyOverlay: replace swaps the payload, merge patches it, both keep the ubus envelope', () => {
const rpc = {
'system.board({})': { jsonrpc: '2.0', id: 1, result: [0, { hostname: 'stand-host', model: 'x86' }] },
'network.device.status({"name":"eth0"})': { jsonrpc: '2.0', id: 2, result: [0, { up: false }] },
};
const out = applyOverlay(rpc, {
'system.board({})': { merge: { hostname: 'footstrap' } },
'network.device.status({"name":"eth0"})': { replace: { up: true, speed: '1000baseT/Full' } },
});
assert.deepEqual(out['system.board({})'].result, [0, { hostname: 'footstrap', model: 'x86' }]);
assert.deepEqual(out['network.device.status({"name":"eth0"})'].result, [0, { up: true, speed: '1000baseT/Full' }]);
/* the recording itself is never mutated */
assert.deepEqual(rpc['system.board({})'].result, [0, { hostname: 'stand-host', model: 'x86' }]);
});
test('applyOverlay throws on a key the recording never saw', () => {
assert.throws(() => applyOverlay({}, { 'system.board({})': { merge: {} } }),
/no entry for "system\.board\(\{\}\)"/);
});
test('applyOverlay throws when an entry names neither replace nor merge', () => {
assert.throws(() => applyOverlay({ 'system.board({})': { result: [0, {}] } }, { 'system.board({})': {} }),
/needs "replace" or "merge"/);
});
test('pruneMenu keeps only recorded leaves and their ancestors', () => {
const menu = {
children: {
status: { title: 'Status', children: {
overview: { title: 'Overview' },
processes: { title: 'Processes' },
} },
network: { title: 'Network', children: {
network: { title: 'Interfaces' },
} },
},
};
const out = pruneMenu(menu, ['status/overview']);
assert.deepEqual(Object.keys(out.children), ['status']);
assert.deepEqual(Object.keys(out.children.status.children), ['overview']);
assert.equal(out.children.status.title, 'Status');
assert.equal(out.children.status.children.overview.title, 'Overview');
});
test('pruneMenu keeps two sibling leaves and drops everything else', () => {
const menu = {
children: {
status: { children: { overview: { title: 'Overview' }, processes: { title: 'Processes' } } },
network: { children: { network: { title: 'Interfaces' } } },
},
};
const out = pruneMenu(menu, ['status/overview', 'status/processes']);
assert.deepEqual(Object.keys(out.children), ['status']);
assert.deepEqual(Object.keys(out.children.status.children).sort(), ['overview', 'processes']);
});
test('scrubDataDeep scrubs a token and the stand host buried in nested rpc data, keys included', () => {
const sid = 'deadbeef'.repeat(4);
const rpc = {
'uci.get({"config":"system"})': {
result: [ 0, { values: { '@system[0]': { hostname: 'x', note: `see http://localhost:8341/${sid}` } } } ],
},
[`session-${sid}`]: { result: [ 0, [ 'localhost:8341' ] ] },
};
const out = scrubDataDeep(rpc, 'localhost:8341');
const flat = JSON.stringify(out);
assert.ok(!flat.includes(sid), 'token survived scrubDataDeep');
assert.ok(!flat.includes('localhost:8341'), 'stand host survived scrubDataDeep');
assert.ok(flat.includes(TOKEN_STUB));
});
test('findSecrets flags a real token and the stand host, not the TOKEN_STUB placeholder', () => {
const sid = 'deadbeef'.repeat(4);
assert.deepEqual(findSecrets(`sid=${TOKEN_STUB}`, 'localhost:8341'), []);
assert.deepEqual(findSecrets(`sid=${sid}`, ''), [ 'a session-token-shaped 32-hex literal' ]);
assert.deepEqual(findSecrets('reachable at localhost:8341', 'localhost:8341'), [ 'the stand host "localhost:8341"' ]);
});
test('guardNoSecrets throws naming the file a planted token sits in, passes a clean set', () => {
const sid = 'deadbeef'.repeat(4);
assert.throws(() => guardNoSecrets([ [ 'cgi-bin/luci/admin/status/overview/index.html', `x=${sid}` ] ], ''),
/cgi-bin\/luci\/admin\/status\/overview\/index\.html/);
assert.doesNotThrow(() => guardNoSecrets([ [ 'a.html', `x=${TOKEN_STUB}` ] ], 'localhost:8341'));
});
test('isSafeReturn accepts only an admin page under base, rejects traversal, backslash, another host and a non-string', () => {
const base = '/luci-theme-footstrap/playground';
assert.equal(isSafeReturn(`${base}/cgi-bin/luci/admin/system/footstrap`, base), true);
assert.equal(isSafeReturn(`${base}/cgi-bin/luci/`, base), false, 'the login page itself is not an admin page');
assert.equal(isSafeReturn('/cgi-bin/luci/admin/system/footstrap', base), false, 'missing base prefix');
assert.equal(isSafeReturn(`${base}/cgi-bin/luci/admin/../../../etc/passwd`, base), false);
assert.equal(isSafeReturn(`${base}/cgi-bin/luci/admin/\\evil`, base), false);
assert.equal(isSafeReturn(`${base}/cgi-bin/luci/admin//evil.example`, base), false);
assert.equal(isSafeReturn(`https://evil.example${base}/cgi-bin/luci/admin/x`, base), false);
assert.equal(isSafeReturn(null, base), false);
assert.equal(isSafeReturn(undefined, base), false);
});
test('resolveUnderRoot keeps an ordinary request inside root, rejects a traversal and a NUL byte', () => {
const root = '/out';
assert.equal(resolveUnderRoot(root, '/cgi-bin/luci/admin/status/overview/index.html'),
'/out/cgi-bin/luci/admin/status/overview/index.html');
assert.equal(resolveUnderRoot(root, '/../../etc/passwd'), null);
assert.equal(resolveUnderRoot(root, '/foo/../../etc/passwd'), null);
assert.equal(resolveUnderRoot(root, '/foo\0bar'), null);
});
test('missingOverlayKeys: every overlay key not present in rpc, none when rpc has them all', () => {
const overlay = { 'system.board({})': {}, 'system.info({})': {} };
assert.deepEqual(missingOverlayKeys({}, overlay), [ 'system.board({})', 'system.info({})' ]);
assert.deepEqual(missingOverlayKeys({ 'system.board({})': {} }, overlay), [ 'system.info({})' ]);
assert.deepEqual(missingOverlayKeys({ 'system.board({})': {}, 'system.info({})': {} }, overlay), []);
assert.deepEqual(missingOverlayKeys({}, {}), []);
});
test('createActivityTracker: quiet only once the set is empty AND quietMs has passed since the last start/finish', () => {
let t = 0;
const now = () => t;
const tracker = createActivityTracker(now);
assert.equal(tracker.quiet(0), true, 'nothing ever started, 0ms is always long enough');
tracker.start('a');
assert.equal(tracker.pendingCount(), 1);
assert.equal(tracker.quiet(0), false, 'still pending');
t = 50;
tracker.finish('a');
assert.equal(tracker.pendingCount(), 0);
assert.equal(tracker.quiet(100), false, 'idle, but not for 100ms yet');
t = 150;
assert.equal(tracker.quiet(100), true);
});
test('createActivityTracker: a slow finish re-opens the clock at FINISH, not at its own start', () => {
let t = 0;
const now = () => t;
const tracker = createActivityTracker(now);
tracker.start('slow-ubus-post');
t = 800; /* a reply slower than a 750ms quiet window would allow if clocked at issue time */
tracker.finish('slow-ubus-post');
assert.equal(tracker.quiet(750), false, 'the clock just reset at finish(), 0ms elapsed since');
t = 800 + 750;
assert.equal(tracker.quiet(750), true);
});
test('createActivityTracker: a second start after the first finishes keeps the window open until IT finishes too', () => {
let t = 0;
const now = () => t;
const tracker = createActivityTracker(now);
tracker.start('a'); t = 10; tracker.finish('a');
t = 20; tracker.start('b'); /* a lazy include firing after the first request already settled */
assert.equal(tracker.quiet(5), false);
t = 30; tracker.finish('b');
assert.equal(tracker.quiet(5), false, 'only 0ms idle since b finished');
t = 36;
assert.equal(tracker.quiet(5), true);
});
test('createActivityTracker: a stale request from an earlier generation never blocks quiet() for the current one', () => {
let t = 0;
const now = () => t;
const tracker = createActivityTracker(now);
tracker.start('gen0-abandoned', 0); /* Playwright drops requestfinished/requestfailed for a
request its own page abandoned on navigation — this one never calls finish() */
t = 10;
tracker.start('gen1-doc', 1);
t = 20;
tracker.finish('gen1-doc');
assert.equal(tracker.quiet(50, 1), false, 'not idle for 50ms yet');
t = 70;
assert.equal(tracker.quiet(50, 1), true, 'gen1 is quiet even though the gen0 request never finished');
assert.equal(tracker.pendingCount(0), 1, 'the abandoned gen0 request is still tracked until pruned');
});
test('createActivityTracker: prune(gen) drops every entry not tagged that generation', () => {
const tracker = createActivityTracker(() => 0);
tracker.start('gen0-a', 0);
tracker.start('gen0-b', 0);
tracker.start('gen1-c', 1);
tracker.prune(1);
assert.equal(tracker.pendingCount(0), 0);
assert.equal(tracker.pendingCount(1), 1);
assert.deepEqual(tracker.pendingEntries(1), [ 'gen1-c' ]);
});
test('describePendingRequest: method + path, no suffix for a plain GET', () => {
assert.equal(
describePendingRequest('GET', 'http://192.168.1.1/luci-static/resources/luci.js', null),
'GET /luci-static/resources/luci.js',
);
});
test('describePendingRequest: appends the ubus object.method a POST body calls', () => {
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'call', params: [ 'sid', 'system', 'board', {} ] });
assert.equal(describePendingRequest('POST', 'http://192.168.1.1/ubus/', body), 'POST /ubus/ [system.board]');
});
test('describePendingRequest: a batched POST names every call in the batch', () => {
const body = JSON.stringify([
{ jsonrpc: '2.0', id: 1, method: 'call', params: [ 'sid', 'system', 'board', {} ] },
{ jsonrpc: '2.0', id: 2, method: 'call', params: [ 'sid', 'uci', 'get', { config: 'system' } ] },
]);
assert.equal(describePendingRequest('POST', 'http://192.168.1.1/ubus/', body), 'POST /ubus/ [system.board, uci.get]');
});
test('describePendingRequest: a POST body that is not a ubus call gets no suffix', () => {
assert.equal(describePendingRequest('POST', 'http://192.168.1.1/cgi-bin/luci/', 'not json'), 'POST /cgi-bin/luci/');
assert.equal(
describePendingRequest('POST', 'http://192.168.1.1/cgi-bin/luci/', JSON.stringify({ method: 'list' })),
'POST /cgi-bin/luci/',
);
});
test('describePendingRequests: joins up to the limit and counts the rest', () => {
const reqs = Array.from({ length: 12 }, (_, i) => ({ method: 'GET', url: `http://h/luci-static/${i}.js`, postData: null }));
const out = describePendingRequests(reqs, 10);
assert.equal(out, `${Array.from({ length: 10 }, (_, i) => `GET /luci-static/${i}.js`).join(', ')}, +2 more`);
});
test('waitForQuiet: the timeout message names the still-pending requests (method + path, ubus call)', async () => {
let t = 0;
const now = () => t;
const tracker = createActivityTracker(now);
const stuck = {
method: 'POST', url: 'http://192.168.1.1/ubus/',
postData: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'call', params: [ 'sid', 'system', 'board', {} ] }),
};
tracker.start(stuck, 1); /* current generation is 1: a page-2 request never settling must not be
blamed on page 1's own, already-pruned, generation-0 entries */
const sleep = async (ms) => { t += ms; };
await assert.rejects(
() => waitForQuiet({
isQuiet: () => tracker.quiet(50, 1),
quietMs: 50, timeoutMs: 100, now, sleep, pollMs: 50,
describe: () => {
const pending = tracker.pendingEntries(1);
return `${pending.length} request(s) still in flight: ${describePendingRequests(pending)}`;
},
}),
/1 request\(s\) still in flight: POST \/ubus\/ \[system\.board\]/,
);
});
test('createGenerationGate: a response tagged before the latest bump() reads as stale, one tagged after as current', () => {
const gate = createGenerationGate();
gate.tag('login-landing-req'); /* generation 0, before any bump — e.g. login()'s own navigation */
assert.equal(gate.isCurrent('login-landing-req'), true, 'still generation 0, nothing has bumped yet');
gate.bump(); /* the capture loop's first page */
assert.equal(gate.isCurrent('login-landing-req'), false, 'a later generation is now current');
gate.tag('page1-req');
assert.equal(gate.isCurrent('page1-req'), true);
gate.bump(); /* the second page */
assert.equal(gate.isCurrent('page1-req'), false);
assert.equal(gate.isCurrent('page1-req'), false, 'reading it again does not change the answer');
});
test('createGenerationGate: an id that was never tagged is never current', () => {
const gate = createGenerationGate();
assert.equal(gate.isCurrent('never-tagged'), false);
gate.bump();
assert.equal(gate.isCurrent('never-tagged'), false);
});
test('waitForQuiet resolves once isQuiet() starts returning true', async () => {
let t = 0;
let quiet = false;
const now = () => t;
const sleep = async (ms) => { t += ms; if (t >= 200) quiet = true; };
const idleFor = await waitForQuiet({
isQuiet: () => quiet, quietMs: 200, timeoutMs: 5000, now, sleep, pollMs: 50,
});
assert.ok(idleFor >= 150, `expected idleFor >= 150, got ${idleFor}`);
});
test('waitForQuiet throws naming the deadline when isQuiet() never turns true', async () => {
let t = 0;
const now = () => t;
const sleep = async (ms) => { t += ms; };
await assert.rejects(
() => waitForQuiet({ isQuiet: () => false, quietMs: 200, timeoutMs: 1000, now, sleep, pollMs: 100 }),
/no 200ms quiet window reached within 1000ms$/,
);
});
test('waitForQuiet appends describe() to the timeout error when given', async () => {
let t = 0;
const now = () => t;
const sleep = async (ms) => { t += ms; };
await assert.rejects(
() => waitForQuiet({
isQuiet: () => false, quietMs: 200, timeoutMs: 1000, now, sleep, pollMs: 100,
describe: () => '2 request(s) still in flight',
}),
/no 200ms quiet window reached within 1000ms \(2 request\(s\) still in flight\)/,
);
});
test('waitForQuiet + createActivityTracker together: quiet only once every in-flight request has finished and settled for quietMs', async () => {
let t = 0;
const now = () => t;
const tracker = createActivityTracker(now);
tracker.start('doc');
const sleep = async (ms) => {
t += ms;
if (t === 50) { tracker.finish('doc'); tracker.start('lazy-include'); } /* a late-firing request */
if (t === 100) tracker.finish('lazy-include');
};
const idleFor = await waitForQuiet({
isQuiet: () => tracker.quiet(50), quietMs: 50, timeoutMs: 5000, now, sleep, pollMs: 25,
});
assert.ok(idleFor >= 100, `expected idleFor >= 100 (idle only starts once "lazy-include" finishes at t=100), got ${idleFor}`);
});
test('drainReads: every read succeeded — settled carries both, nothing abandoned', async () => {
const result = await drainReads([
[ 'a', Promise.resolve('ok') ],
[ 'b', Promise.resolve('ok') ],
]);
assert.deepEqual(result.abandoned, []);
assert.deepEqual(result.settled.map((s) => [ s.label, s.status ]), [ [ 'a', 'fulfilled' ], [ 'b', 'fulfilled' ] ]);
});
test('drainReads: never throws — a rejected read comes back in settled, not as an exception', async () => {
const result = await drainReads([
[ 'good', Promise.resolve('ok') ],
[ 'ubus POST /ubus/', Promise.reject(new Error('closed')) ],
[ 'GET /luci-static/x.js', Promise.reject(new Error('navigated')) ],
]);
assert.deepEqual(result.abandoned, []);
const byLabel = Object.fromEntries(result.settled.map((s) => [ s.label, s ]));
assert.equal(byLabel.good.status, 'fulfilled');
assert.equal(byLabel['ubus POST /ubus/'].status, 'rejected');
assert.equal(byLabel['ubus POST /ubus/'].reason.message, 'closed');
assert.equal(byLabel['GET /luci-static/x.js'].status, 'rejected');
assert.equal(byLabel['GET /luci-static/x.js'].reason.message, 'navigated');
});
test('drainReads: a read that never settles is named in `abandoned`, never thrown', async () => {
let releaseHung;
const hung = new Promise((resolve) => { releaseHung = resolve; });
const result = await drainReads([
[ 'GET /luci-static/x.js', Promise.resolve('ok') ],
[ 'ubus POST /ubus/', hung ],
], 30);
assert.deepEqual(result.abandoned, [ 'ubus POST /ubus/' ]);
assert.deepEqual(result.settled.map((s) => s.label), [ 'GET /luci-static/x.js' ]);
releaseHung('ok'); /* let the dangling promise settle so it cannot leak into a later test */
});