diff --git a/packages/webui/README.md b/packages/webui/README.md index 6656f1ff..c6664678 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -566,11 +566,18 @@ 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, and assert that none is armed for the board while the toggle is +off, even across Pause and Resume. 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 diff --git a/packages/webui/tests/board-held.test.mjs b/packages/webui/tests/board-held.test.mjs index 19b875a8..d4f81726 100644 --- a/packages/webui/tests/board-held.test.mjs +++ b/packages/webui/tests/board-held.test.mjs @@ -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,16 @@ 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 polls with setTimeout(refresh, 10000) and bus.js with an arrow function; both use 10000. + const timeout = window.setTimeout.bind(window); + window.setTimeout = (f, ms, ...rest) => { + if (ms !== 10000) return timeout(f, ms, ...rest); + window.__polls.push(f.name === 'refresh' ? '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 +60,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 +70,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,6 +108,12 @@ 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'); @@ -117,6 +136,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 +158,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 +174,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'), []); }; diff --git a/packages/webui/tests/browser-edge.test.mjs b/packages/webui/tests/browser-edge.test.mjs index 446c9cc2..efb261ff 100644 --- a/packages/webui/tests/browser-edge.test.mjs +++ b/packages/webui/tests/browser-edge.test.mjs @@ -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');