mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-01 05:55:18 +02:00
fix(control-pane): bound snapshot loads and automatic polling
Preserve ordered manual/query recovery while giving snapshot headers and bodies one ten-second deadline. Keep action mutation behavior unchanged.
Source-PR: https://github.com/affaan-m/ECC/pull/3235
Source-Parent: aff9c64560
Review-Manifest-SHA256: 59efa4136448129df673d047e633e88351c98cc1fcdc812f475575bc6f6ce201
This commit is contained in:
@@ -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));
|
||||
</script>
|
||||
|
||||
@@ -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('<script>') + '<script>'.length;
|
||||
return html.slice(start, html.lastIndexOf('</script>'));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user