diff --git a/scripts/lib/control-pane/ui.js b/scripts/lib/control-pane/ui.js index d42a13eae..38573b291 100644 --- a/scripts/lib/control-pane/ui.js +++ b/scripts/lib/control-pane/ui.js @@ -443,6 +443,9 @@ function renderControlPaneHtml() { // the newest data any load brought, and the error box the outcome of the // newest load that has finished, so a load that finishes late can neither // replace newer data nor overrule a newer outcome. + const SNAPSHOT_DEADLINE_MS = 10000; + const SNAPSHOT_POLL_INTERVAL_MS = 15000; + let snapshotsInFlight = 0; let loadsStarted = 0; let newestFinished = 0; let shownLoad = 0; @@ -656,11 +659,28 @@ function renderControlPaneHtml() { async function load(live = false) { const id = ++loadsStarted; const query = state.query; + snapshotsInFlight++; + let snapshotTimer = null; try { const url = new URL('/api/snapshot', window.location.href); if (query) url.searchParams.set('query', query); - const response = await fetch(url); - const snapshot = await readJsonResponse(response); + const controller = new AbortController(); + let rejectDeadline; + const deadline = new Promise((_resolve, reject) => { rejectDeadline = reject; }); + snapshotTimer = setTimeout(() => { + // Reject first so the fixed timeout wins over an abort rejection. + rejectDeadline(new Error('Snapshot request timed out after 10 seconds.')); + controller.abort(); + }, SNAPSHOT_DEADLINE_MS); + // Only fetch/body decoding races the deadline. Late abort-ignoring + // responses have no rendering or state side effects after losing. + const snapshot = await Promise.race([ + (async () => { + const response = await fetch(url, { signal: controller.signal }); + return readJsonResponse(response); + })(), + deadline + ]); // A snapshot that cannot be shown fails its load like one that could // not be fetched, and older data may still take the board. if (id > shownLoad) { @@ -673,6 +693,9 @@ function renderControlPaneHtml() { failure = { error, live }; showFailure(); return; + } finally { + if (snapshotTimer !== null) clearTimeout(snapshotTimer); + snapshotsInFlight--; } if (id < newestFinished) { // A newer load failed first. This data is still the newest on the @@ -756,11 +779,12 @@ function renderControlPaneHtml() { .catch(error => showError('#app', error)); }; - // Live board: refresh on a gentle interval; pause while a prompt/tab is hidden. + // Only automatic polls coalesce. Manual/query loads retain ordered overlap. + // Browser suspension may delay timers; this is not a wall-clock server SLA. setInterval(() => { - if (document.hidden) return; + if (document.hidden || snapshotsInFlight > 0) return; load(true).catch(error => showError('#app', error)); - }, 15000); + }, SNAPSHOT_POLL_INTERVAL_MS); load().catch(error => showError('#app', error)); diff --git a/tests/lib/control-pane-ui.test.js b/tests/lib/control-pane-ui.test.js index a00cbf12f..922f1afe7 100644 --- a/tests/lib/control-pane-ui.test.js +++ b/tests/lib/control-pane-ui.test.js @@ -1,33 +1,38 @@ /** * Tests for the browser script the local ECC2 control pane serves. */ - const assert = require('assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); const vm = require('vm'); - const { buildControlPaneSnapshot } = require('../../scripts/lib/control-pane/state'); const { renderControlPaneHtml } = require('../../scripts/lib/control-pane/ui'); - +const pages = new Set(); async function test(name, fn) { - try { - await fn(); - console.log(` PASS ${name}`); - return true; - } catch (error) { + let failed = false; + let failure; + try { await fn(); } catch (error) { failed = true; failure = error; } + finally { + for (const page of pages) { + try { await page.dispose(); } catch (error) { + if (!failed) { failed = true; failure = error; } + } + } + pages.clear(); + } + if (failed) { console.log(` FAIL ${name}`); - console.log(` Error: ${error.message}`); + console.log(` Error: ${failure?.message ?? String(failure)}`); return false; } + console.log(` PASS ${name}`); + return true; } - function inlineScript(html) { const start = html.indexOf('')); } - // The page's clock. The page shows times with toLocaleString, which follows // the locale's calendar (a Thai locale counts Buddhist years), so a test // compares against the same call on this instant. @@ -64,7 +69,45 @@ function openPage(snapshot, { hold = false } = {}) { } return elements.get(selector); }; - const page = { online: true, hold, pending: [], requests: [], refresh: null, element, now: NOW }; + const page = { online: true, hold, pending: [], requests: [], refresh: null, element, now: NOW, + timers: new Map(), timerCalls: [], clearCalls: [], tick: 0, ignoreAbort: false }; + let timerId = 0; + class FakeAbortController { + constructor() { + const listeners = new Set(); + this.signal = { aborted: false, listeners, + addEventListener: (_type, callback) => listeners.add(callback), + removeEventListener: (_type, callback) => listeners.delete(callback) }; + this.aborts = 0; + } + abort() { + this.aborts++; + if (this.signal.aborted) return; + this.signal.aborted = true; + for (const listener of this.signal.listeners) listener(); + } + } + page.fireTimer = id => { + const timer = page.timers.get(id); + assert.ok(timer, 'Expected a pending deadline'); + page.timers.delete(id); + timer.callback(); + }; + page.advance = ms => { + page.tick += ms; + for (const [id, timer] of [...page.timers]) if (timer.at <= page.tick) page.fireTimer(id); + }; + page.dispose = async () => { + await settle(); + for (const request of page.requests) request.reply.fail(new Error('Fixture disposed')); + await settle(); + const remaining = page.timers.size; + page.timers.clear(); + assert.strictEqual(remaining, 0, 'Every load must remove its deadline after fixture settlement'); + assert.ok(page.requests.every(request => request.reply.settled), 'All fake requests must settle'); + assert.ok(page.requests.every(request => !request.options.signal || request.options.signal.listeners.size === 0), 'Fake abort listeners must be removed'); + }; + pages.add(page); class Clock extends PageDate { constructor(...args) { super(...(args.length > 0 ? args : [page.now.getTime()])); @@ -77,22 +120,65 @@ function openPage(snapshot, { hold = false } = {}) { Intl, Date: Clock, console, + AbortController: FakeAbortController, + setTimeout: (callback, ms) => { + const id = ++timerId; + page.timerCalls.push({ id, ms }); + page.timers.set(id, { callback, at: page.tick + ms }); + return id; + }, + clearTimeout: id => { page.clearCalls.push(id); page.timers.delete(id); }, fetch: (url, options = {}) => new Promise((resolve, reject) => { - page.requests = [...page.requests, { url: String(url), options }]; - const reply = { - succeed: (data = snapshot) => resolve({ ok: true, status: 200, statusText: 'OK', json: async () => data }), - fail: () => reject(new TypeError('Failed to fetch')) + let bodyResolve; + let bodyReject; + let headers = false; + let queuedBody; + const reply = { settled: false, ignoreAbort: page.ignoreAbort }; + const finish = () => { + reply.settled = true; + options.signal?.removeEventListener('abort', onAbort); }; - if (page.hold) page.pending = [...page.pending, reply]; + const onAbort = () => { + if (!reply.ignoreAbort) reply.fail(new Error('Synthetic AbortError')); + }; + reply.respond = response => { + if (reply.settled || headers) return; + headers = true; + resolve({ ...response, json: async () => { + try { return await response.json(); } finally { finish(); } + } }); + }; + reply.headers = () => reply.respond({ ok: true, status: 200, json: () => new Promise((accept, refuse) => { + bodyResolve = accept; bodyReject = refuse; + if (queuedBody) (queuedBody.error ? refuse : accept)(queuedBody.error || queuedBody.data); + }) }); + reply.succeed = (data = snapshot) => { + if (reply.settled) return; + if (!headers) reply.respond({ ok: true, status: 200, statusText: 'OK', json: async () => data }); + else if (bodyResolve) bodyResolve(data); + else queuedBody = { data }; + }; + reply.fail = (error = new TypeError('Failed to fetch')) => { + if (reply.settled) return; + if (!headers) { finish(); reject(error); } + else if (bodyReject) bodyReject(error); + else queuedBody = { error }; + }; + page.requests.push({ url: String(url), options, reply }); + options.signal?.addEventListener('abort', onAbort); + if (page.hold) page.pending.push(reply); else if (page.online) reply.succeed(); else reply.fail(); }), - setInterval: callback => { + setInterval: (callback, ms) => { + page.intervalMs = ms; page.refresh = callback; } }; - vm.runInNewContext(inlineScript(renderControlPaneHtml()), page.script); + vm.createContext(page.script); + vm.runInContext(inlineScript(renderControlPaneHtml()), page.script); + page.state = () => JSON.parse(vm.runInContext('JSON.stringify({ loadedAt: loadedAt && loadedAt.getTime(), shownLoad, loadsStarted, newestFinished, query: state.query, shownQuery: state.shownQuery, allowActions: state.allowActions, active: typeof snapshotsInFlight === "undefined" ? null : snapshotsInFlight })', page.script)); return page; } @@ -142,6 +228,8 @@ async function runTests() { let failed = 0; const snapshot = await isolatedSnapshot(); + // The original ordering cases below explicitly dispatch load(true) where + // overlap is intentional. Automatic interval coalescing is tested separately. if ( await test('a failed live refresh is reported, and cleared by the next one that succeeds', async () => { @@ -173,9 +261,9 @@ async function runTests() { await settle(); page.hold = true; - page.refresh(); + page.script.load(true); page.hold = false; - page.refresh(); + page.script.load(true); await settle(); page.pending[0].fail(); await settle(); @@ -191,9 +279,9 @@ async function runTests() { await settle(); page.hold = true; - page.refresh(); - page.refresh(); - page.refresh(); + page.script.load(true); + page.script.load(true); + page.script.load(true); page.pending[1].succeed(); await settle(); page.pending[0].fail(); @@ -210,8 +298,8 @@ async function runTests() { await settle(); page.hold = true; - page.refresh(); - page.refresh(); + page.script.load(true); + page.script.load(true); page.pending[0].succeed(); await settle(); page.pending[1].fail(); @@ -230,8 +318,8 @@ async function runTests() { await settle(); page.hold = true; - page.refresh(); - page.refresh(); + page.script.load(true); + page.script.load(true); page.pending[1].fail(); await settle(); page.pending[0].succeed(); @@ -251,8 +339,8 @@ async function runTests() { const answer = query => ({ ...snapshot, knowledge: { ...snapshot.knowledge, query } }); page.hold = true; - page.refresh(); - page.refresh(); + page.script.load(true); + page.script.load(true); page.pending[1].succeed(answer('newer')); await settle(); page.pending[0].succeed(answer('older')); @@ -268,7 +356,7 @@ async function runTests() { const page = openPage(snapshot, { hold: true }); const answer = query => ({ ...snapshot, knowledge: { ...snapshot.knowledge, query } }); - page.refresh(); + page.script.load(true); page.pending[1].fail(); await settle(); page.pending[0].succeed(answer('first')); @@ -289,7 +377,7 @@ async function runTests() { page.hold = true; page.element('#refresh').listeners.click(); - page.refresh(); + page.script.load(true); page.pending[1].succeed(); await settle(); page.pending[0].fail(); @@ -306,7 +394,7 @@ async function runTests() { await settle(); page.hold = true; - page.refresh(); + page.script.load(true); page.element('#refresh').listeners.click(); page.pending[1].fail(); await settle(); @@ -332,8 +420,8 @@ async function runTests() { }; page.hold = true; - page.refresh(); - page.refresh(); + page.script.load(true); + page.script.load(true); page.pending[1].succeed(unshowable); await settle(); const box = page.element('#app'); @@ -397,7 +485,7 @@ async function runTests() { await settle(); const before = displayedBoard(page); page.now = new Date(NOW.getTime() + 60_000); - page.refresh(); + page.script.load(true); const invalid = { ...original, dbPath: 'new-database', database: { exists: true }, @@ -454,6 +542,259 @@ async function runTests() { ) passed++; else failed++; + + const answer = (query, allowActions = false) => ({ + ...snapshot, knowledge: { ...snapshot.knowledge, query }, execution: { allowActions }, + }); + const search = (page, query) => { + page.element('#query').value = query; + page.element('#query-form').listeners.submit({ preventDefault() {} }); + }; + const deadlineTests = [ + ['actual intervals coalesce a stalled request, then timeout and recover', async () => { + const page = openPage(snapshot); + await settle(); + const before = displayedBoard(page); + const accepted = page.state().loadedAt; + page.hold = true; + page.refresh(); + const request = page.requests[1]; + for (let i = 0; i < 4; i++) page.refresh(); + assert.strictEqual(page.requests.length, 2, 'Only one automatic load may be active'); + assert.strictEqual(page.state().active, 1); + assert.strictEqual(page.intervalMs, 15000); + assert.strictEqual(page.timerCalls.at(-1).ms, 10000); + page.advance(9999); + await settle(); + assert.strictEqual(page.element('#app').hidden, true); + assert.strictEqual(request.options.signal.aborted, false); + page.advance(1); + await settle(); + assert.strictEqual(request.options.signal.aborted, true); + assert.strictEqual(page.state().active, 0); + assert.strictEqual(page.timers.size, 0); + assert.deepStrictEqual(displayedBoard(page), before); + assert.strictEqual(page.state().loadedAt, accepted); + assert.match(page.element('#app').textContent, /Live refresh failed[\s\S]*Snapshot request timed out after 10 seconds/); + assert.ok(page.element('#app').textContent.includes(NOW.toLocaleString())); + page.hold = false; + page.now = new Date(NOW.getTime() + 60_000); + page.refresh(); + await settle(); + assert.strictEqual(page.requests.length, 3); + assert.strictEqual(page.element('#app').hidden, true); + assert.strictEqual(page.state().loadedAt, page.now.getTime()); + assert.strictEqual(page.timers.size, 0); + }], + ['one total deadline includes headers and a stalled JSON body', async () => { + const page = openPage(snapshot); + await settle(); + const before = displayedBoard(page); + page.hold = true; + page.refresh(); + page.advance(4000); + page.pending[0].headers(); + await settle(); + page.advance(5999); + await settle(); + assert.strictEqual(page.element('#app').hidden, true); + page.advance(1); + await settle(); + assert.match(page.element('#app').textContent, /Snapshot request timed out after 10 seconds/); + assert.doesNotMatch(page.element('#app').textContent, /Synthetic AbortError/); + assert.deepStrictEqual(displayedBoard(page), before); + assert.strictEqual(page.requests[1].options.signal.aborted, true); + assert.strictEqual(page.state().active, 0); + }], + ['expired header response cannot overwrite a newer accepted query or permission', async () => { + const page = openPage(snapshot); + await settle(); + page.hold = true; page.ignoreAbort = true; + page.refresh(); + const late = page.pending[0]; + page.advance(10000); + await settle(); + search(page, 'new query'); + page.pending[1].succeed(answer('new query', true)); + await settle(); + const before = displayedBoard(page); + const state = page.state(); + late.succeed(answer('expired query', false)); + await settle(); + assert.deepStrictEqual(displayedBoard(page), before); + assert.deepStrictEqual(page.state(), state); + assert.strictEqual(page.element('#app').hidden, true); + }], + ['expired body response cannot overwrite a newer failure or release its active counter twice', async () => { + const page = openPage(snapshot); + await settle(); + page.hold = true; page.ignoreAbort = true; + page.refresh(); + const late = page.pending[0]; + late.headers(); + await settle(); + page.advance(10000); + await settle(); + page.refresh(); + page.pending[1].fail(new Error('Newer failure')); + await settle(); + const failure = page.element('#app').textContent; + const before = displayedBoard(page); + page.refresh(); // Another load remains active while the expired loser finishes. + late.succeed(answer('expired query', true)); + await settle(); + assert.deepStrictEqual(displayedBoard(page), before); + assert.strictEqual(page.element('#app').textContent, failure); + assert.strictEqual(page.state().active, 1); + page.refresh(); + assert.strictEqual(page.requests.length, 4, 'Late completion must not reopen the automatic dispatch gate'); + page.pending[2].succeed(); + await settle(); + assert.strictEqual(page.state().active, 0); + }], + ['expired late fetch rejection stays handled and preserves the newer outcome', async () => { + const page = openPage(snapshot); + await settle(); + page.hold = true; page.ignoreAbort = true; + page.refresh(); + const late = page.pending[0]; + page.advance(10000); + await settle(); + page.refresh(); + page.pending[1].succeed(); + await settle(); + const state = page.state(); + late.fail(new Error('Late ignored abort rejection')); + await settle(); + assert.deepStrictEqual(page.state(), state); + assert.strictEqual(page.element('#app').hidden, true); + }], + ['initial timeout is visible without an invented last-good timestamp', async () => { + const page = openPage(snapshot, { hold: true }); + page.refresh(); + assert.strictEqual(page.requests.length, 1, 'Initial load also suppresses automatic dispatch'); + page.advance(10000); + await settle(); + assert.strictEqual(page.element('#app').hidden, false); + assert.match(page.element('#app').textContent, /Snapshot request timed out/); + assert.doesNotMatch(page.element('#app').textContent, /data below is from/); + assert.strictEqual(page.state().loadedAt, null); + page.hold = false; + page.refresh(); + await settle(); + assert.strictEqual(page.element('#app').hidden, true); + assert.strictEqual(page.state().loadedAt, NOW.getTime()); + }], + ['manual and query loads may overlap a poll while actual intervals remain suppressed', async () => { + const page = openPage(snapshot); + await settle(); + page.hold = true; + page.refresh(); + page.element('#refresh').listeners.click(); + search(page, 'query'); + assert.strictEqual(page.requests.length, 4); + assert.strictEqual(page.state().active, 3); + page.pending[1].succeed(); + await settle(); + assert.strictEqual(page.state().active, 2); + page.refresh(); + assert.strictEqual(page.requests.length, 4); + page.pending[2].succeed(answer('query')); + await settle(); + assert.strictEqual(page.state().active, 1); + page.advance(10000); + await settle(); + assert.strictEqual(page.state().active, 0); + assert.strictEqual(page.element('#app').hidden, true, 'Older timeout does not overrule newer success'); + page.refresh(); + assert.strictEqual(page.requests.length, 5); + }], + ['newer manual timeout remains visible when an older valid load supplies fallback data', async () => { + const page = openPage(snapshot); + await settle(); + page.hold = true; + search(page, 'older'); + search(page, 'newer'); + // Adversarial completion order: fire only the newer timer, not wall time. + page.fireTimer(page.timerCalls.at(-1).id); + await settle(); + page.pending[0].succeed(answer('older', true)); + await settle(); + assert.strictEqual(page.element('#query').value, 'older'); + assert.strictEqual(page.state().shownQuery, 'older'); + assert.strictEqual(page.state().allowActions, true); + assert.match(page.element('#app').textContent, /Snapshot request timed out/); + assert.doesNotMatch(page.element('#app').textContent, /Live refresh failed/); + assert.strictEqual(page.state().active, 0); + }], + ['hidden interval leaves existing failure intact until a visible successful refresh', async () => { + const page = openPage(snapshot); + await settle(); + page.online = false; + page.refresh(); + await settle(); + const failure = page.element('#app').textContent; + page.script.document.hidden = true; + page.refresh(); + assert.strictEqual(page.requests.length, 2); + assert.strictEqual(page.element('#app').textContent, failure); + page.script.document.hidden = false; page.online = true; + page.refresh(); + await settle(); + assert.strictEqual(page.element('#app').hidden, true); + }], + ['action POST remains unbounded by snapshot timers and its reload gets a deadline', async () => { + const page = openPage(snapshot); + await settle(); + page.hold = true; + const action = page.script.runAction('recall-knowledge'); + assert.strictEqual(page.requests[1].options.method, 'POST'); + assert.strictEqual(page.requests[1].options.signal, undefined); + assert.strictEqual(page.timers.size, 0); + page.advance(10000); + assert.strictEqual(page.pending[0].settled, false); + page.pending[0].succeed({ ok: true }); + await settle(); + assert.match(page.requests[2].url, /\/api\/snapshot/); + assert.ok(page.requests[2].options.signal); + assert.strictEqual(page.timers.size, 1); + page.pending[1].succeed(); + await action; + assert.strictEqual(page.timers.size, 0); + }], + ]; + for (const route of ['HTTP failure', 'invalid JSON', 'malformed late section', 'fetch setup', 'controller setup', 'URL setup', 'timer setup']) { + deadlineTests.push([`${route} releases its timer and active counter for the next interval`, async () => { + const page = openPage(snapshot); + await settle(); + page.hold = true; + const key = { 'fetch setup': 'fetch', 'controller setup': 'AbortController', 'URL setup': 'URL', 'timer setup': 'setTimeout' }[route]; + const original = page.script[key]; + if (key) page.script[key] = function () { throw new Error(route); }; + page.refresh(); + if (route === 'HTTP failure') page.pending[0].respond({ ok: false, json: async () => ({ error: route }) }); + if (route === 'invalid JSON') page.pending[0].respond({ ok: true, json: async () => { throw new Error(route); } }); + if (route === 'malformed late section') page.pending[0].succeed({ ...snapshot, actions: {} }); + await settle(); + assert.strictEqual(page.element('#app').hidden, false); + assert.strictEqual(page.state().active, 0); + assert.strictEqual(page.timers.size, 0); + if (key) page.script[key] = original; + page.hold = false; + const count = page.requests.length; + page.refresh(); + await settle(); + assert.strictEqual(page.requests.length, count + 1); + assert.strictEqual(page.element('#app').hidden, true); + assert.strictEqual(page.state().active, 0); + }]); + } + for (const [name, check] of deadlineTests) { + if (await test(name, check)) passed++; + else failed++; + } + assert.strictEqual(pages.size, 0, 'Every fake page and its pending requests were disposed'); + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); }