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
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
asked for `redirect: 'manual'`. Then a redirect answer on a board read, an
`agents` read and a send: one line, no NetworkError, nothing stale left, no
further request on a route change, Refresh or the toggle, and the draft
kept. `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,
asked for `redirect: 'manual'`. The tests record every 10 s poll timer the
page arms, filed by the script that armed it, and assert that none is armed
for the board while the toggle is off, even across Pause and Resume, and that
one is armed once it is on. Then a redirect answer on each of six
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.
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
// 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
// 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 assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
@@ -29,9 +33,17 @@ const businesses = {
},
};
const STUB = `(() => {
const stub = fast => `(() => {
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'); } });
window.fetch = async (url, init = {}) => {
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 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.
@@ -59,22 +71,24 @@ async function setup(machine) {
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
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}/`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub(fast) });
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 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 reqs = () => b.evaluate('window.__reqs');
const polls = () => b.evaluate('window.__polls');
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 }); };
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 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 () => {
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 {
// Off by default: the held roles show, the machine scan is never read.
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();
assert.equal((await reqs()).some(r => r.path === '/api/board'), 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.
await b.evaluate('document.querySelector("#show-machine").click(), true');
await wait(SESSIONS);
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}');
await b.navigate(`${base}?on`);
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);
await settle();
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.
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 () => {
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 out = async () => {
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);
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);
// No further Console fetch: not on a route change, a Refresh, the toggle or the poll.
const n = (await reqs()).length;
// No further Console fetch and no new poll timer: not on a route change, a Refresh, the toggle
// 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('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();
assert.equal((await reqs()).length, n);
assert.equal((await polls()).length, armed);
assert.equal(await b.evaluate('!!document.querySelector("#bus-view h1")'), false);
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/);
await b.evaluate('document.querySelector("#show-machine").click()');
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));
await wait('document.querySelector("#sessions").textContent.includes("No sessions")');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');