webui: Board tests fail fast and the README lists every redirect (#1591)

Row 97, the row 95 follow-up. Researcher's build, round 3 (agents/researcher/work/queue-97/r3/), a
confirm-only rebase of round 2 onto row 94. Darkwing (comment 28140) and Filbert (comment 28139) approved
round 3. Researcher's check on bb45f75e: webui 107 pass, bus.js mutants 26 and 27 killed (r3/check.md).
Sage's gate rerun on 865a8fc2: all 14 package node suites and all 9 scripts/test-*.sh green. Tests and
README only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 09:41:29 -05:00
co-authored by Claude Opus 5.5
parent 865a8fc2ea
commit 17217c7788
3 changed files with 56 additions and 16 deletions
+13 -5
View File
@@ -567,11 +567,19 @@ claim that has an address and one that has none. They check the held roles
with the machine sessions off by default and no `/api/board` request, the with the machine sessions off by default and no `/api/board` request, the
toggle on and off and its persistence across reloads, Message addressing toggle on and off and its persistence across reloads, Message addressing
the send form to each role, no action without send, and that every fetch the send form to each role, no action without send, and that every fetch
asked for `redirect: 'manual'`. Then a redirect answer on a board read, an asked for `redirect: 'manual'`. The tests record every 10 s poll timer the
`agents` read and a send: one line, no NetworkError, nothing stale left, no page arms, filed by the script that armed it, and assert that none is armed
further request on a route change, Refresh or the toggle, and the draft for the board while the toggle is off, even across Pause and Resume, and that
kept. `send-browser.test.mjs` also checks the sign-out after its redirect one is armed once it is on. Then a redirect answer on each of six
receipt and that a reload works again. Since the held roles lead the Board, requests, the board read (`/api/board`), the `agents` read, `/api/conversations`,
`/api/seen`, `/api/queue` and the send: one line, no NetworkError, nothing
stale left, and no further request or new poll timer on a route change,
Refresh, the toggle, a forced Pause click or a poll tick, and the draft kept. In
that test the poll timers fire after 150 ms, so the check spans several
ticks. `browser-edge.test.mjs` fails in 5 s, and closes the browser and
servers, if the toggle makes no `/api/board` request. `send-browser.test.mjs`
also checks the sign-out after its redirect receipt and that a reload works
again. Since the held roles lead the Board,
the shell test's no-scroll check at 1440px uses a 1000px-tall window. the shell test's no-scroll check at 1440px uses a 1000px-tall window.
Bus tests (`bus.test.mjs`, `bus-browser.test.mjs`) run the routes and the Bus tests (`bus.test.mjs`, `bus-browser.test.mjs`) run the routes and the
+39 -10
View File
@@ -5,7 +5,11 @@
// through its reader session. A script added before the page loads records every fetch the page // through its reader session. A script added before the page loads records every fetch the page
// makes, answers /api/config with send on (unless the URL has ?nosend), answers POST // makes, answers /api/config with send on (unless the URL has ?nosend), answers POST
// /api/bus/message from a queue, and answers any path in window.__redirect the way fetch does // /api/bus/message from a queue, and answers any path in window.__redirect the way fetch does
// under redirect: 'manual' (type opaqueredirect, status 0). // under redirect: 'manual' (type opaqueredirect, status 0). It also records every 10 s poll timer
// the page arms (window.__polls, 'board' for app.js and 'bus' for bus.js). The second test has the
// timers fire after 150 ms, so its "no further request" check spans real poll ticks, and both
// tests assert on the timers themselves: a guard that only the fetch behind it backs up would
// otherwise change nothing the page sends.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs'; import { mkdtempSync, rmSync } from 'node:fs';
@@ -29,9 +33,17 @@ const businesses = {
}, },
}; };
const STUB = `(() => { const stub = fast => `(() => {
const real = window.fetch.bind(window); const real = window.fetch.bind(window);
window.__reqs = []; window.__answers = []; window.__redirect = []; window.__reqs = []; window.__answers = []; window.__redirect = []; window.__polls = [];
// app.js and bus.js both poll with a 10000 delay. A timer is filed by the script that armed it, read
// from the call stack, not from the shape of the callback, which either script may change.
const timeout = window.setTimeout.bind(window);
window.setTimeout = (f, ms, ...rest) => {
if (ms !== 10000) return timeout(f, ms, ...rest);
window.__polls.push(/\\/app\\.js[:?]/.test(new Error().stack) ? 'board' : 'bus');
return timeout(f, ${fast} ? 150 : ms, ...rest);
};
const redirect = () => ({ type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } }); const redirect = () => ({ type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } });
window.fetch = async (url, init = {}) => { window.fetch = async (url, init = {}) => {
const u = String(url), path = u.split('?')[0]; const u = String(url), path = u.split('?')[0];
@@ -49,7 +61,7 @@ const STUB = `(() => {
}; };
})();`; })();`;
async function setup(machine) { async function setup(machine, fast = false) {
const root = mkdtempSync(join(tmpdir(), 'webui-board-held-')); const root = mkdtempSync(join(tmpdir(), 'webui-board-held-'));
const store = new Store(root), b0 = new Broker({ store, businesses }); const store = new Store(root), b0 = new Broker({ store, businesses });
// pm's run has an address; coder's claim has none, which the Board still lists. // pm's run has an address; coder's claim has none, which the Board still lists.
@@ -59,22 +71,24 @@ async function setup(machine) {
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) }); const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser({ machine }); const f = await fixture(), b = await browser({ machine });
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`; const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub(fast) });
await b.viewport(1280); await b.viewport(1280);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`); const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`); const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`); const shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`);
const reqs = () => b.evaluate('window.__reqs'); const reqs = () => b.evaluate('window.__reqs');
const polls = () => b.evaluate('window.__polls');
const settle = () => new Promise(r => setTimeout(r, 600)); const settle = () => new Promise(r => setTimeout(r, 600));
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); }; const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
return { b, base, wait, text, shown, reqs, settle, done }; return { b, base, wait, text, shown, reqs, polls, settle, done };
} }
const HELD = 'document.querySelectorAll("#held table tbody tr").length === 2'; const HELD = 'document.querySelectorAll("#held table tbody tr").length === 2';
const SESSIONS = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")'; const SESSIONS = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")';
test('the Board: held roles with no machine sessions, a claim with no address, the toggle kept across a reload, and Message addressed to the role', { timeout: 120000 }, async () => { test('the Board: held roles with no machine sessions, a claim with no address, the toggle kept across a reload, and Message addressed to the role', { timeout: 120000 }, async () => {
const { b, base, wait, text, shown, reqs, settle, done } = await setup(false); const { b, base, wait, text, shown, reqs, polls, settle, done } = await setup(false);
const boardPolls = async () => (await polls()).filter(p => p === 'board').length;
try { try {
// Off by default: the held roles show, the machine scan is never read. // Off by default: the held roles show, the machine scan is never read.
await b.navigate(base); await b.navigate(base);
@@ -95,11 +109,19 @@ test('the Board: held roles with no machine sessions, a claim with no address, t
await settle(); await settle();
assert.equal((await reqs()).some(r => r.path === '/api/board'), false); assert.equal((await reqs()).some(r => r.path === '/api/board'), false);
assert.equal(await b.evaluate(`!!${SESSIONS}`), false); assert.equal(await b.evaluate(`!!${SESSIONS}`), false);
// The board is not polled either: no timer for it, even after Pause and Resume, which re-arm one.
assert.equal(await boardPolls(), 0);
assert.ok((await polls()).includes('bus'), 'the bus views still poll');
await b.evaluate('document.querySelector("#pause").click(), document.querySelector("#pause").click(), true');
await settle();
assert.equal(await boardPolls(), 0);
// On: the machine sessions are read and shown, and the choice is kept for the next load. // On: the machine sessions are read and shown, and the choice is kept for the next load.
await b.evaluate('document.querySelector("#show-machine").click(), true'); await b.evaluate('document.querySelector("#show-machine").click(), true');
await wait(SESSIONS); await wait(SESSIONS);
assert.equal(await shown('#board-view'), true); assert.equal(await shown('#board-view'), true);
// Positive control for the zero checks above and below: with the toggle on, the board does arm its timer.
await wait("window.__polls.includes('board')");
assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-board")'), '{"machine":true}'); assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-board")'), '{"machine":true}');
await b.navigate(`${base}?on`); await b.navigate(`${base}?on`);
await wait(SESSIONS); await wait(SESSIONS);
@@ -117,6 +139,7 @@ test('the Board: held roles with no machine sessions, a claim with no address, t
assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), false); assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), false);
await settle(); await settle();
assert.equal((await reqs()).some(r => r.path === '/api/board'), false); assert.equal((await reqs()).some(r => r.path === '/api/board'), false);
assert.equal(await boardPolls(), 0);
// Message opens the send form addressed to that role, with the caret in the message. // Message opens the send form addressed to that role, with the caret in the message.
for (const role of ['coder', 'pm']) { for (const role of ['coder', 'pm']) {
@@ -138,7 +161,8 @@ test('the Board: held roles with no machine sessions, a claim with no address, t
}); });
test('a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads', { timeout: 120000 }, async () => { test('a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads', { timeout: 120000 }, async () => {
const { b, base, wait, text, shown, reqs, settle, done } = await setup(true); // The poll timers fire after 150 ms here, so the 600 ms settle below spans several ticks.
const { b, base, wait, text, shown, reqs, polls, settle, done } = await setup(true, true);
const LINE = 'Signed out. Reload the page to sign in.'; const LINE = 'Signed out. Reload the page to sign in.';
const out = async () => { const out = async () => {
await wait('!document.querySelector("#signed-out").hidden'); await wait('!document.querySelector("#signed-out").hidden');
@@ -153,12 +177,17 @@ test('a redirect answer on a Board read, a bus read, a conversation read, a boar
assert.equal(await b.evaluate('document.querySelector("#held").children.length'), 0); assert.equal(await b.evaluate('document.querySelector("#held").children.length'), 0);
for (const sel of ['#held-section', '#board-view', '#board-head', '#error']) assert.equal(await shown(sel), false, sel); for (const sel of ['#held-section', '#board-view', '#board-head', '#error']) assert.equal(await shown(sel), false, sel);
assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true); assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true);
// No further Console fetch: not on a route change, a Refresh, the toggle or the poll. // No further Console fetch and no new poll timer: not on a route change, a Refresh, the toggle
const n = (await reqs()).length; // or a poll tick (a timer armed before the sign-out may still fire, and finds nothing to read).
const n = (await reqs()).length, armed = (await polls()).length;
await b.evaluate(`location.hash = location.hash === '#/agents' ? '#/inbox' : '#/agents', true`); await b.evaluate(`location.hash = location.hash === '#/agents' ? '#/inbox' : '#/agents', true`);
await b.evaluate('document.querySelector("#refresh").click(), document.querySelector("#show-machine").click(), document.querySelector("#show-machine").click(), true'); await b.evaluate('document.querySelector("#refresh").click(), document.querySelector("#show-machine").click(), document.querySelector("#show-machine").click(), true');
// Pause is disabled when signed out. Enabled by force for a pause and a resume, its handlers must still
// arm nothing: the guard in each schedule() is the only thing between the resume and a timer.
await b.evaluate('(() => { const p = document.querySelector("#pause"); p.disabled = false; p.click(); p.click(); p.disabled = true; return true; })()');
await settle(); await settle();
assert.equal((await reqs()).length, n); assert.equal((await reqs()).length, n);
assert.equal((await polls()).length, armed);
assert.equal(await b.evaluate('!!document.querySelector("#bus-view h1")'), false); assert.equal(await b.evaluate('!!document.querySelector("#bus-view h1")'), false);
assert.deepEqual((await reqs()).filter(r => r.redirect !== 'manual'), []); assert.deepEqual((await reqs()).filter(r => r.redirect !== 'manual'), []);
}; };
+4 -1
View File
@@ -26,7 +26,10 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Machine sessions hidden/); assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Machine sessions hidden/);
await b.evaluate('document.querySelector("#show-machine").click()'); await b.evaluate('document.querySelector("#show-machine").click()');
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Loading/); assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Loading/);
await boardRequested; // Fails in 5 s if the toggle never reads the board; the finally below then closes the browser and servers.
let timer;
try { await Promise.race([boardRequested, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Show machine sessions made no /api/board request in 5 s')), 5000); })]); }
finally { clearTimeout(timer); }
holdBoard = false; boardResponse.end(JSON.stringify(index)); holdBoard = false; boardResponse.end(JSON.stringify(index));
await wait('document.querySelector("#sessions").textContent.includes("No sessions")'); await wait('document.querySelector("#sessions").textContent.includes("No sessions")');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0'); assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');