From 4d8123b0a55d124db0684041421adf6f3777a213 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 23 Oct 2026 14:07:48 -0500 Subject: [PATCH] row 198 r4: port the four fake-board tests to the in-process board (sage-3597, sage-3603) browser-edge, preview, tester-listener and shell read an in-process board now, not a fake HTTP server. Changed expectations and deleted refusal-only assertions are listed in BUILD.md for Mos (mos-1272). Adds granted-user R6 G4b: a granted session gets 403 owner-only on all five board paths before any read, and the owner reaches them. UNVERIFIED: these tests were not run (slot rule). Gate still waits for CLEAR. --- agents/bushroot/work/queue-198/BUILD.md | 72 ++++++++++++ packages/webui/tests/browser-edge.test.mjs | 27 ++--- packages/webui/tests/granted-user.test.mjs | 34 +++++- packages/webui/tests/preview.test.mjs | 28 ++--- packages/webui/tests/shell.test.mjs | 107 +++++++----------- packages/webui/tests/tester-listener.test.mjs | 8 +- 6 files changed, 178 insertions(+), 98 deletions(-) diff --git a/agents/bushroot/work/queue-198/BUILD.md b/agents/bushroot/work/queue-198/BUILD.md index 34683c827..793e1e126 100644 --- a/agents/bushroot/work/queue-198/BUILD.md +++ b/agents/bushroot/work/queue-198/BUILD.md @@ -295,3 +295,75 @@ need a rerun, because the drift check did not cover them: they are upstream-only UNVERIFIED. No node, no test run, and no mutant has run on r4. The gate waits for Sage's CLEAR, after 226, 220/216 and 285 in gate order. The four fake-board tests above are known red. The rest are unknown until the run. + +## r4: the four fake-board tests ported to the in-process board (sage-3597, sage-3603) + +Scope: the four tests that read the retired fake HTTP board server, plus one new granted-user test. Numstat is +against 14fe7513c, the r4 tip before this entry. Nothing in this section has been run. The gate is still +UNVERIFIED, and the tests here were not executed (slot rule). Sage sends the list below to Mos (mos-1272). + +| File | +/- | What changed | +|---|---|---| +| `packages/webui/tests/browser-edge.test.mjs` | 14/13 | The fake server is gone. `index()` holds while `holdBoard` is set and resolves to the page's board on release. `reply()` holds in place of the `held` response and returns `{status, body}`. The 5 s "made no /api/board request" check still runs: it now watches `index()` being called. Every assertion is kept. | +| `packages/webui/tests/preview.test.mjs` | 12/16 | `setup()` builds an in-process board that records `GET /api/board`, `POST /api/seen` and `POST /api/reply` in `boardHits`. The 401 and read-only tests keep their `boardHits` checks. The dead-board case is a board whose `index()` throws, so the answer is 502 `invalid-response`. The upstream origin is no longer in the body (see the deleted assertion below). | +| `packages/webui/tests/tester-listener.test.mjs` | 4/4 | The first test's `hits` counter moves into an in-process `index()`. Every bus and board read still counts, and both 403 checks are unchanged. The `createServer` import is removed. | +| `packages/webui/tests/shell.test.mjs` | 44/63 | `board()` is an in-process board. Modes: `empty`, `one`, `registered`, `fail` (throws, so 502 `invalid-response`), `unavailable` (throws `ReadError('read-failed')`, so 503). `hold()` gates `index()`. The conversation catalogue and page are sync. Changed expectations (listed below). Refusal-only steps removed (listed below). | +| `packages/webui/tests/granted-user.test.mjs` | 32/2 | `setup()` takes `board` and `send` options, defaulting to the old `null` and `false`. New test `R6 G4b`: a granted session gets 403 `owner-only` on all five board paths with no board read, and the owner session gets 200 on each, with the read order checked. | + +### Changed expectations (not loosened; each is a product change, for Mos) + +These expectations now name the in-process contract. The message text came from the fake server before. Each one is +an old assertion with a new value. + +- `shell.test.mjs` stale-data check: `/board scan failed.*Showing last known data/` is now `/invalid-response.*Showing last known data/`. A scan throw is mapped by `consoleRead` to `invalid-response` (502). Before, the fake returned 500 with the text. +- `shell.test.mjs` unavailable check: `/board unavailable.*No board data loaded/` is now `/read-failed.*No board data loaded/`. `consoleRead` sends the code as `error`, and the page shows `err.message`, which is that code. The page shows `read-failed` now, where it showed `board unavailable` before. +- `preview.test.mjs` dead board: the status stays 502, and `body.error` is now `invalid-response`. + +### Deleted assertions (proxy-only, no in-process counterpart) + +- `preview.test.mjs` `assert.equal(board.body.board, dead)`. The in-process board has no origin, so the body + can't name one. The status and the bus checks around it are kept. +- `shell.test.mjs` the refusal block: the fake's HTTP 403 `board-refused`. `consoleRead` has no 403 code: `STATUS` + in `reads.mjs` maps only `not-configured`, `no-bus-host`, `queue-refused`, `read-failed`, `not-found` and + `invalid-request`, and any other code is 502. So the in-process board cannot refuse, and `app.js`'s + `err.status === 403` branch ("The board refused the read", `#board-refused`, the fail-closed drop of kept + rows, the inspector and the counts) has no trigger in these tests. The block that was deleted asserted every + one of these. The conversation refusal is deleted too: a 403 from `/api/conversation` closed the open + conversation, and the new step closes it with `#conv-back`. + This is a coverage loss, and it needs a decision. Either `reads.mjs` maps a board refusal to 403 (a product + change, which Mos rules on), or the refusal branch is retired. The granted-user 403 `owner-only` answer goes + through the same `err.status === 403` branch, so the page path is still reached by granted users. It is not + covered by a shell check. + +### Status change, dead board (Webby's question, webby-0229) + +A board failure used to answer by the origin's state: a dead origin was 502 with the origin named, a board 500 +was 500, and a 503 passed through. Now: + +- no board configured (`board` null): 503 `not-configured`; +- a `ReadError` with a code in `STATUS`: that code's status; +- any other throw (a scan failure): 502 `invalid-response` on `/api/board`. The 500 applies only on the + conversation routes (`conversation read failed`). + +No test under `packages/webui/tests` asserts 502 on a board path other than the preview dead-board case above. +The grep: `usage`, `limits-route`, `routes`, `chat`, `library-status`, `thread-claims`, `send`, `bus`, +`project-file`, `tester-listener`, `send-browser`, `instances`, `tester-cli` and `agents`. Those 502s belong to bus, +chat, or project-file routes, which this change does not touch. + +### Granted-user gate (sage-3603): the test and its mutant + +The board is a closure that the granted rebind doesn't touch, so `userRoute` is the only gate in front of it +for a granted session. Upstream's null-origin backstop is gone. Test `R6 G4b` pins that: the granted session gets +403 `owner-only` on `GET /api/board`, `GET /api/conversations`, `GET /api/conversation`, `POST /api/seen` and +`POST /api/reply`, and `reads` is still empty after those five requests. The owner session gets 200 on each, and +`reads` shows the five calls in order. + +Mutant M18 (not run): in `packages/webui/src/serve.mjs`, change the granted gate +`if (!userRoute(path, req.method)) return json(res, 403, ...)` so it passes when `path` is one of the five board +paths. Expected: `R6 G4b` fails at the granted 403 assertion, because the granted request reaches the board and +`reads` is not empty. Run it with the rest of the granted-user suite once Sage gives CLEAR. The kill is not yet shown. + +### Gate status + +UNVERIFIED. These four files and the granted-user test are edited but not run. The rule "commit only after suites +are green" is not yet met, and this commit says so. The coverage loss above needs Sage's ruling before the gate. diff --git a/packages/webui/tests/browser-edge.test.mjs b/packages/webui/tests/browser-edge.test.mjs index efb261ff4..e328e05c4 100644 --- a/packages/webui/tests/browser-edge.test.mjs +++ b/packages/webui/tests/browser-edge.test.mjs @@ -1,22 +1,23 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { createServer } from 'node:http'; import { browser } from './browser.mjs'; import { close } from './fixture.mjs'; import { startServer } from '../src/serve.mjs'; test('browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback', { timeout: 120000 }, async () => { - let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, boardResponse; + let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, releaseBoard; let resolveBoardRequest; const boardRequested = new Promise(resolve => { resolveBoardRequest = resolve; }); - const server = createServer(async (req, res) => { - for await (const chunk of req) { void chunk; } - res.setHeader('content-type', 'application/json'); - if (req.url === '/api/board') { if (holdBoard) { boardResponse = res; resolveBoardRequest(); return; } return res.end(JSON.stringify(index)); } - sends++; held = res; - }); - await new Promise(r => server.listen(0, '127.0.0.1', r)); - const web = await startServer({ port: 0, board: `http://127.0.0.1:${server.address().port}` }); + // The board runs in-process (row 198): index() is the page's /api/board read, reply() its reply. + const board = { + index: () => { + if (!holdBoard) return index; + resolveBoardRequest(); + return new Promise(resolve => { releaseBoard = resolve; }); + }, + reply: () => { sends++; return new Promise(resolve => { held = resolve; }); }, + }; + const web = await startServer({ port: 0, board }); const b = await browser(); 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')})()`); try { @@ -30,7 +31,7 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values 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; releaseBoard(index); 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.documentElement.dataset.palette'), 'harbor'); @@ -52,7 +53,7 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); assert.equal(await b.evaluate('document.querySelector(".reply-form button").disabled'), true); await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'newer ' }); - held.end(JSON.stringify({ delivered: true, sentAt: 'fixture', session: agent })); + held({ status: 200, body: { delivered: true, sentAt: 'fixture', session: agent } }); await wait('document.querySelector(".receipt")?.textContent.startsWith("delivered")'); assert.equal(sends, 1); assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/); // A stale registration removes the form, but does not destroy its draft. @@ -92,5 +93,5 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values assert.match(await b.evaluate('document.querySelector("#error").textContent'), /Invalid board session data/); assert.equal(await b.evaluate('document.querySelectorAll("table.sessions [data-open]").length'), 1); assert.deepEqual(await b.evaluate('window.errors'), []); - } finally { boardResponse?.end(); held?.end(); await b.close(); await close(web); await close(server); } + } finally { releaseBoard?.(index); held?.({ status: 200, body: {} }); await b.close(); await close(web); } }); diff --git a/packages/webui/tests/granted-user.test.mjs b/packages/webui/tests/granted-user.test.mjs index c4a3e3656..55ca649f0 100644 --- a/packages/webui/tests/granted-user.test.mjs +++ b/packages/webui/tests/granted-user.test.mjs @@ -51,7 +51,7 @@ const ALICE = { id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'al // The fake OP, an in-process broker for business `demo`, createSso with the access hook, and the // human listener. The owner's options are counting stubs; each user session's options are // consoleBus over that user's transport. `counts` records every owner stub and user transport call. -async function setup(t, { users = [ALICE], grants = [grant()] } = {}) { +async function setup(t, { users = [ALICE], grants = [grant()], board = null, send = false } = {}) { const op = await fakeOidc(); t.after(() => op.close()); const iss = op.issuer('a'); @@ -103,7 +103,7 @@ async function setup(t, { users = [ALICE], grants = [grant()] } = {}) { reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(v)])), chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(v)])), }; - const web = await startServer({ port: 0, board: null, sso, log, ...owner, userOptions, federation: FEDERATION }); + const web = await startServer({ port: 0, board, send, sso, log, ...owner, userOptions, federation: FEDERATION }); t.after(() => close(web)); const p1 = broker.request(human, { verb: 'project.create', args: { name: 'shared' } }).id; const p2 = broker.request(human, { verb: 'project.create', args: { name: 'private' } }).id; @@ -246,6 +246,36 @@ test('R6 G4 every owner route answers 403 owner-only before any bus call', async assert.equal(events(env), before, 'something was written'); }); +// Row 198 (sage-3603): the board runs in-process behind userRoute alone; upstream's null-origin backstop +// is gone. A granted session is refused on all five board paths before any board read, and the owner's +// session reaches each one. The mutant that drops the userRoute check for these paths must fail here. +test('R6 G4b the five board paths: a granted session is refused before any board read, the owner reaches all five', async t => { + const reads = []; + const board = { + index: async () => { reads.push('index'); return { sessions: [], counts: {}, generatedAt: 'fixture' }; }, + conversation: url => { reads.push(url.pathname); return { status: 200, body: { conversations: [] } }; }, + markSeen: async () => { reads.push('markSeen'); return { sessions: [] }; }, + reply: async () => { reads.push('reply'); return { status: 200, body: { delivered: true } }; }, + }; + const env = await setup(t, { board, send: true }); + const { session: granted } = await signIn(env, 'alice-sub'); + const { session: owner } = await signIn(env, 'owner-sub'); + const seen = JSON.stringify({ project: 'p', agent: 'a', lastActivity: 'x', seen: true }), reply = JSON.stringify({ agent: 'p/a', text: 'hi' }); + const paths = [['GET', '/api/board'], ['GET', '/api/conversations'], ['GET', '/api/conversation?conversation=c1'], ['POST', '/api/seen', seen], ['POST', '/api/reply', reply]]; + const headers = (method, cookie) => (method === 'GET' ? { cookie } : writeHeaders(cookie)); + for (const [method, path, body] of paths) { + const r = await call(env.port, { method, path, headers: headers(method, granted), body }); + assert.equal(r.status, 403, `granted ${method} ${path}`); + assert.equal(r.body.error, 'owner-only', `granted ${method} ${path}`); + } + assert.deepEqual(reads, [], 'a granted session reached the board'); + for (const [method, path, body] of paths) { + const r = await call(env.port, { method, path, headers: headers(method, owner), body }); + assert.equal(r.status, 200, `owner ${method} ${path}: ${JSON.stringify(r.body)}`); + } + assert.deepEqual(reads, ['index', '/api/conversations', '/api/conversation', 'markSeen', 'reply']); +}); + test('R6 G5 the bus refuses every owner verb over the user transport on its own', async t => { const env = await setup(t); const o = consoleBus(env.userTransport('alice'), { send: true, owner: env.ownerCall }); diff --git a/packages/webui/tests/preview.test.mjs b/packages/webui/tests/preview.test.mjs index bc0c7a90d..3a8fbe97a 100644 --- a/packages/webui/tests/preview.test.mjs +++ b/packages/webui/tests/preview.test.mjs @@ -1,5 +1,3 @@ -import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs'; -import { after as laneAfter } from 'node:test'; // Row 74 (#1568, lead decision 93): the preview mode. A request for the public name needs the // proxy's header equal to the one user id, else 401 with nothing read; in preview mode nothing // but GET and HEAD gets through, whatever the Host; loopback requests work as they did. Round 2: @@ -7,7 +5,7 @@ import { after as laneAfter } from 'node:test'; // fails closed instead of reaching the loopback path. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { createServer, request } from 'node:http'; +import { request } from 'node:http'; import { connect } from 'node:net'; import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; @@ -46,20 +44,17 @@ function raw(port, lines) { const signedIn = { [HEADER]: ID }; const json = { 'content-type': 'application/json' }; -// A board that counts what reaches it, and a bus that counts its reads. +// A board that counts what reaches it (in-process, row 198), and a bus that counts its reads. async function setup(t, options = {}) { const boardHits = []; - const upstream = createServer((req, res) => { - boardHits.push(`${req.method} ${req.url}`); - res.writeHead(200, json); - res.end(req.url === '/api/board' ? '{"sessions":[]}' : '{"ok":true}'); - }); - await new Promise(r => upstream.listen(0, '127.0.0.1', r)); - // Closed first and on its own, so a refused startServer doesn't leave it holding the process open. - t.after(() => close(upstream)); + const board = { + index: async () => { boardHits.push('GET /api/board'); return { sessions: [] }; }, + markSeen: async () => { boardHits.push('POST /api/seen'); return { sessions: [] }; }, + reply: async () => { boardHits.push('POST /api/reply'); return { status: 200, body: { delivered: true } }; }, + }; const busCalls = []; const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }])); - const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, ...options }); + const web = await startServer({ port: 0, board, bus, preview, ...options }); t.after(() => close(web)); return { port: web.address().port, boardHits, busCalls }; } @@ -207,8 +202,9 @@ test('in preview mode every write answers 403 read-only, from the public name an }); test('loopback requests are unchanged in preview mode, and the bus views work with the board down', async t => { - const dead = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`; - const { port, busCalls } = await setup(t, { board: dead, timeout: 200 }); + // The board is down: every read throws, which the Console answers as invalid-response (502). + const down = { index: async () => { throw new Error('board down'); } }; + const { port, busCalls } = await setup(t, { board: down }); const loopback = { host: `127.0.0.1:${port}` }; const inbox = await send(port, { headers: loopback }); assert.equal(inbox.status, 200); assert.deepEqual(inbox.body.rows, [{ verb: 'inbox' }]); @@ -217,7 +213,7 @@ test('loopback requests are unchanged in preview mode, and the bus views work wi assert.equal((await send(port, { headers: { host: `127.0.0.1:${port + 1}` } })).status, 403); // The board panel's existing error; the bus views still answer. const board = await send(port, { path: '/api/board', headers: signedIn }); - assert.equal(board.status, 502); assert.equal(board.body.board, dead); + assert.equal(board.status, 502); assert.equal(board.body.error, 'invalid-response'); assert.equal((await send(port, { path: '/api/bus/agents', headers: signedIn })).status, 200); assert.deepEqual(busCalls.map(c => c[0]), ['inbox', 'agents']); }); diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs index 996b5c958..7a1961a23 100644 --- a/packages/webui/tests/shell.test.mjs +++ b/packages/webui/tests/shell.test.mjs @@ -7,13 +7,13 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; -import { createServer } from 'node:http'; import { mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { browser } from './browser.mjs'; import { close } from './fixture.mjs'; import { startServer } from '../src/serve.mjs'; import { BusReadError } from '../src/bus.mjs'; +import { ReadError } from '../src/reads.mjs'; const pub = new URL('../src/public/', import.meta.url), design = new URL('../../../docs/design/', import.meta.url); const read = (name, base = pub) => readFileSync(new URL(name, base)); @@ -68,31 +68,41 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () = assert.equal(read('icons.svg').toString().includes(' { - for await (const chunk of req) { void chunk; } - await gate; - const path = new URL(req.url, 'http://localhost').pathname; - const body = reply.status !== 200 ? reply.body : path === '/api/conversations' ? catalogue : path === '/api/conversation' ? page : reply.body; - res.writeHead(reply.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); - }); + const answer = () => { + if (mode === 'fail') throw new Error('board scan failed'); + if (mode === 'unavailable') throw new ReadError('read-failed', 'board unavailable'); + if (mode === 'one') return { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() }; + if (mode === 'registered') return { sessions: [{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], counts: { waiting: 1 }, generatedAt: new Date().toISOString() }; + return { sessions: [], counts: {}, generatedAt: new Date().toISOString() }; + }; + const set = next => () => { mode = next; }; return { - server, - // Holds every reply until the returned function is called. + board: { + // Waits while held, so the page shows its loading state. + index: async () => { await gate; return answer(); }, + // The catalogue and the page are sync, as the Console calls them; a failing mode throws, which the route answers 500. + conversation: url => { + if (mode === 'fail' || mode === 'unavailable') throw new Error('conversation read failed'); + return { status: 200, body: url.pathname === '/api/conversations' ? catalogue : page }; + }, + }, + // Holds every board read until the returned function is called. hold: () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; }, - empty: () => { reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } }; }, - one: () => { reply = { status: 200, body: { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() } }; }, - fail: () => { reply = { status: 500, body: { error: 'board scan failed' } }; }, - refuse: () => { reply = { status: 403, body: { error: 'board-refused' } }; }, + empty: set('empty'), + one: set('one'), + fail: set('fail'), // One seat a reply can reach: registered, alive, with a tmux session. - registered: () => { reply = { status: 200, body: { sessions: [{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], counts: { waiting: 1 }, generatedAt: new Date().toISOString() } }; }, - unavailable: () => { reply = { status: 503, body: { error: 'board unavailable' } }; }, + registered: set('registered'), + unavailable: set('unavailable'), }; } @@ -106,9 +116,9 @@ function stubBus() { } test('shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette', { timeout: 180000 }, async () => { - const brd = board(); await new Promise(r => brd.server.listen(0, '127.0.0.1', r)); + const brd = board(); const s = stubBus(); - const web = await startServer({ port: 0, board: `http://127.0.0.1:${brd.server.address().port}`, bus: s.bus }); + const web = await startServer({ port: 0, board: brd.board, bus: s.bus }); const base = `http://127.0.0.1:${web.address().port}/`, b = await browser(); const out = process.env.WEBUI_EVIDENCE; if (out) mkdirSync(out, { recursive: true }); @@ -170,39 +180,18 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct assert.equal(await b.evaluate('getComputedStyle(document.querySelector("table.sessions .st-waiting"),"::before").content'), '"โ—" / ""'); brd.fail(); await refresh(); assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err'); - assert.match(await text('#error'), /board scan failed.*Showing last known data\. Scanned /); + assert.match(await text('#error'), /invalid-response.*Showing last known data\. Scanned /); assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 2); assert.match(await text('#fresh-board'), /stale: the last refresh failed/); await flat(); await shot('board-stale-400'); - // Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from - // them: the open inspector, the project filter, the project tree's counts and the footer. - await b.evaluate('document.querySelector("#projects [data-project=proj]").click()'); - assert.equal(await b.evaluate('document.querySelector("#projects [data-project=proj]").getAttribute("aria-pressed")'), 'true'); - await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('!document.querySelector("#inspector").hidden'); - assert.equal(await text('#footer'), 'waiting 1 ยท working 1'); - assert.equal(await b.evaluate('document.querySelectorAll("#projects .count").length'), 2); - assert.equal(await count('#seen .wait-item'), 1); - brd.refuse(); await refresh(); - assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner ref'); - assert.match(await text('#error'), /The board refused the read\. refused: board-refused/); - assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none'); - assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false); - assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), true); - assert.equal(await b.evaluate('document.body.classList.contains("has-inspector")'), false); - assert.equal(await b.evaluate('document.querySelectorAll("#projects .count, #projects [data-project]").length'), 0); - assert.equal(await text('#footer'), 'Board not read: refused'); - assert.equal(await text('#fresh-board'), 'Board not read: refused'); - assert.doesNotMatch(await text('#status'), /Scanned/); - for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), 'โ€“', id); - await flat(); await shot('board-refused-400'); - // A failed read after the refusal shows the board again, empty: no row, card or count from - // the refused scan, and the footer says what the banner says. With nothing focused, focus stays put. + // A failed first read on a fresh load: the board is drawn empty and the footer says what the banner says. + // With nothing focused, focus stays put. (Before row 198 this followed a refusal; the refusal is gone.) + brd.unavailable(); await b.navigate(base); await wait('!document.querySelector("#error").hidden'); await b.evaluate('document.activeElement.blur()'); - brd.unavailable(); await refresh(); assert.equal(await b.evaluate('document.activeElement === document.body'), true, 'a failure with nothing focused leaves focus alone'); assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err'); - assert.match(await text('#error'), /board unavailable.*No board data loaded\./); + assert.match(await text('#error'), /read-failed.*No board data loaded\./); assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none'); assert.equal(await count('#sessions tr, #sessions [data-open], #waiting .wait-item, #seen .wait-item, #board-view [data-history]'), 0); for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), 'โ€“', id); @@ -210,7 +199,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct assert.equal(await text('#fresh-board'), 'Board not read: the read failed'); assert.equal(await text('#status'), 'Not read: the read failed'); assert.equal(await text('#projects'), 'Not read: the read failed.'); - await flat(); await shot('board-refused-then-failed-400'); + await flat(); await shot('board-unavailable-400'); brd.one(); await refresh(); assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true); assert.equal(await b.evaluate('document.querySelector("#projects [data-project=\'\']").getAttribute("aria-pressed")'), 'true'); @@ -219,8 +208,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct // A good read after a failed one drops the stale words. brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/); brd.one(); await refresh(); assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); assert.doesNotMatch(await text('#fresh-board'), /stale/); - // A refusal with a conversation open closes it and clears its history and reply form: the - // session is in no scan the page holds. + // A conversation is opened from a registered session, and a failed catalogue read clears the picker. brd.registered(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===1'); await b.evaluate('document.querySelector("table.sessions [data-history]").click()'); await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")'); @@ -232,15 +220,8 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct assert.equal(await count('#conv-pick option'), 0, 'no stale session in the picker'); brd.registered(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()'); await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")'); - brd.refuse(); await refresh(); - assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true); - assert.equal(await b.evaluate('document.body.classList.contains("has-conversation")'), false); - assert.equal(await count('#conv-log > *'), 0); - assert.equal(await b.evaluate('document.querySelector("#conv-form").checkVisibility()'), false); - assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none'); - assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false); - // Fix to HEAD behaviour: focus went back to History, which the refusal removed; it lands on the page, not . - assert.equal(await b.evaluate('document.activeElement.id'), 'main', 'focus after a refusal closes the conversation'); + // Back to the board, closed as the refusal used to close it. + await b.evaluate('document.getElementById("conv-back").click()'); await wait('document.querySelector("#conversation").hidden'); brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2'); // Keyboard on the board: down moves between rows, Enter opens, Esc closes and returns focus. @@ -410,21 +391,21 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct assert.ok(scrollH > innerH, `the board page is taller than ${innerH}px`); assert.deepEqual([tree[0], tree[1]], [bar[1], foot[0]]); assert.deepEqual(await b.evaluate('window.errors'), []); - } finally { await b.close(); await close(web); await close(brd.server); } + } finally { await b.close(); await close(web); } }); // Every restyled view in its five states: loading (a held read), normal, empty, a failed read // over kept data, and a refusal. Empty is seeded per view above; here each view is read once // with rows, then made to fail, then refused, at 400px. test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }, async () => { - const brd = board(); await new Promise(r => brd.server.listen(0, '127.0.0.1', r)); + const brd = board(); const s = stubBus(); const at = new Date().toISOString(), id = 'd'.repeat(32); s.rows.inbox = [{ id, question: 'Push it?', class: 'gated', action: 'git.push.protected', target: 'refactor', blocking: true, options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', at, raised_by: 'coder' }]; s.rows.trail = [{ table: 'decisions', seq: 1, at, id, class: 'gated', action: 'git.push.protected', route_to: 'human', question: 'Push it?', options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', blocking: 1, raised_by_role: 'coder', raised_by_run: 'coder-run' }]; s.rows.tasks = [{ task_ref: 'vikunja:32/7', fields: { title: 'Kept task', bucket: 2, done: false, assignees: [] }, source: 'self' }, { task_ref: 'vikunja:32/8', fields: { title: 'Second task', bucket: 2, done: false, assignees: [] }, source: 'self' }]; s.rows.agents = [{ role: 'coder', holder_run: 'coder-run', harness: 'pi', address: 'coder@bus', at }]; - const web = await startServer({ port: 0, board: `http://127.0.0.1:${brd.server.address().port}`, bus: s.bus }); + const web = await startServer({ port: 0, board: brd.board, bus: s.bus }); const base = `http://127.0.0.1:${web.address().port}/`, b = await browser(); const out = process.env.WEBUI_EVIDENCE; if (out) mkdirSync(out, { recursive: true }); @@ -525,5 +506,5 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 } assert.deepEqual(await b.evaluate('window.errors'), []); assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []); - } finally { await b.close(); await close(web); await close(brd.server); } + } finally { await b.close(); await close(web); } }); diff --git a/packages/webui/tests/tester-listener.test.mjs b/packages/webui/tests/tester-listener.test.mjs index 4d49f1dc3..5517206b9 100644 --- a/packages/webui/tests/tester-listener.test.mjs +++ b/packages/webui/tests/tester-listener.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { request, createServer } from 'node:http'; +import { request } from 'node:http'; import { startServer } from '../src/serve.mjs'; import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs'; import { tmp } from '../../cli/tests/helpers.mjs'; @@ -24,9 +24,9 @@ export const post = (web, path, data, headers = {}) => http(web, path, { method: test('main listener refuses x-mosaic-tester before any board or bus read for every Host', async t => { let hits = 0; - const upstream = createServer((q, r) => { hits++; r.end('{}'); }); - await new Promise(r => upstream.listen(0, '127.0.0.1', r)); t.after(() => close(upstream)); - const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, preview: { host: 'console.example', header: 'x-user', user: 'jason' }, bus: { agents: async () => { hits++; return []; } } }); + // The board runs in-process (row 198): a read that reaches it counts as a hit. + const board = { index: async () => { hits++; return { sessions: [] }; } }; + const web = await startServer({ port: 0, board, preview: { host: 'console.example', header: 'x-user', user: 'jason' }, bus: { agents: async () => { hits++; return []; } } }); t.after(() => close(web)); for (const host of [`127.0.0.1:${web.address().port}`, 'console.example', 'other.example']) for (const path of ['/api/board', '/api/bus/agents']) {