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.
This commit is contained in:
jason.woltje committed 2026-10-23 14:07:48 -05:00
1 parent 14fe7513ce
commit 4d8123b0a5
6 files changed
+178 -98

No files matched your search

+72
View File
@@ -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, 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 220/216 and 285 in gate order. The four fake-board tests above are known red. The rest are unknown until the
run. 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.
+14 -13
View File
@@ -1,22 +1,23 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { browser } from './browser.mjs'; import { browser } from './browser.mjs';
import { close } from './fixture.mjs'; import { close } from './fixture.mjs';
import { startServer } from '../src/serve.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 () => { 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; let resolveBoardRequest;
const boardRequested = new Promise(resolve => { resolveBoardRequest = resolve; }); const boardRequested = new Promise(resolve => { resolveBoardRequest = resolve; });
const server = createServer(async (req, res) => { // The board runs in-process (row 198): index() is the page's /api/board read, reply() its reply.
for await (const chunk of req) { void chunk; } const board = {
res.setHeader('content-type', 'application/json'); index: () => {
if (req.url === '/api/board') { if (holdBoard) { boardResponse = res; resolveBoardRequest(); return; } return res.end(JSON.stringify(index)); } if (!holdBoard) return index;
sends++; held = res; resolveBoardRequest();
}); return new Promise(resolve => { releaseBoard = resolve; });
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}` }); reply: () => { sends++; return new Promise(resolve => { held = resolve; }); },
};
const web = await startServer({ port: 0, board });
const b = await browser(); 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')})()`); 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 { try {
@@ -30,7 +31,7 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
let timer; 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); })]); } 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); } finally { clearTimeout(timer); }
holdBoard = false; boardResponse.end(JSON.stringify(index)); holdBoard = false; releaseBoard(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');
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor'); 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'); 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); 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 ' }); 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")'); await wait('document.querySelector(".receipt")?.textContent.startsWith("delivered")');
assert.equal(sends, 1); assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/); 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. // 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.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.equal(await b.evaluate('document.querySelectorAll("table.sessions [data-open]").length'), 1);
assert.deepEqual(await b.evaluate('window.errors'), []); 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); }
}); });
+32 -2
View File
@@ -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 // 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 // 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. // 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(); const op = await fakeOidc();
t.after(() => op.close()); t.after(() => op.close());
const iss = op.issuer('a'); 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)])), 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)])), 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)); t.after(() => close(web));
const p1 = broker.request(human, { verb: 'project.create', args: { name: 'shared' } }).id; const p1 = broker.request(human, { verb: 'project.create', args: { name: 'shared' } }).id;
const p2 = broker.request(human, { verb: 'project.create', args: { name: 'private' } }).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'); 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 => { test('R6 G5 the bus refuses every owner verb over the user transport on its own', async t => {
const env = await setup(t); const env = await setup(t);
const o = consoleBus(env.userTransport('alice'), { send: true, owner: env.ownerCall }); const o = consoleBus(env.userTransport('alice'), { send: true, owner: env.ownerCall });
+12 -16
View File
@@ -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 // 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 // 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: // 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. // fails closed instead of reaching the loopback path.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createServer, request } from 'node:http'; import { request } from 'node:http';
import { connect } from 'node:net'; import { connect } from 'node:net';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -46,20 +44,17 @@ function raw(port, lines) {
const signedIn = { [HEADER]: ID }; const signedIn = { [HEADER]: ID };
const json = { 'content-type': 'application/json' }; 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 = {}) { async function setup(t, options = {}) {
const boardHits = []; const boardHits = [];
const upstream = createServer((req, res) => { const board = {
boardHits.push(`${req.method} ${req.url}`); index: async () => { boardHits.push('GET /api/board'); return { sessions: [] }; },
res.writeHead(200, json); markSeen: async () => { boardHits.push('POST /api/seen'); return { sessions: [] }; },
res.end(req.url === '/api/board' ? '{"sessions":[]}' : '{"ok":true}'); reply: async () => { boardHits.push('POST /api/reply'); return { status: 200, body: { delivered: 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 busCalls = []; const busCalls = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }])); 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)); t.after(() => close(web));
return { port: web.address().port, boardHits, busCalls }; 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 => { 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})}`; // The board is down: every read throws, which the Console answers as invalid-response (502).
const { port, busCalls } = await setup(t, { board: dead, timeout: 200 }); 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 loopback = { host: `127.0.0.1:${port}` };
const inbox = await send(port, { headers: loopback }); const inbox = await send(port, { headers: loopback });
assert.equal(inbox.status, 200); assert.deepEqual(inbox.body.rows, [{ verb: 'inbox' }]); 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); 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. // The board panel's existing error; the bus views still answer.
const board = await send(port, { path: '/api/board', headers: signedIn }); 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.equal((await send(port, { path: '/api/bus/agents', headers: signedIn })).status, 200);
assert.deepEqual(busCalls.map(c => c[0]), ['inbox', 'agents']); assert.deepEqual(busCalls.map(c => c[0]), ['inbox', 'agents']);
}); });
+44 -63
View File
@@ -7,13 +7,13 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { createServer } from 'node:http';
import { mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { mkdirSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { browser } from './browser.mjs'; import { browser } from './browser.mjs';
import { close } from './fixture.mjs'; import { close } from './fixture.mjs';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.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 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)); 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('<script'), false); assert.equal(read('icons.svg').toString().includes('<script'), false);
}); });
// The Console's board, in-process (row 198). Each mode sets what the next read answers. A thrown
// ReadError answers its own code; any other throw answers invalid-response (consoleRead's mapping).
// A scan that fails answers the same as a board that is down: the page keeps or drops data as before.
function board() { function board() {
let reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } }; let mode = 'empty', gate = null;
const one = { agent: 'agent1', project: 'proj', state: 'waiting', waitingOnYou: true, task: 'Fixture task', lastActivity: '2026-10-10T00:00:00Z', lastAssistantText: 'Input needed: fixture', registered: null }; const one = { agent: 'agent1', project: 'proj', state: 'waiting', waitingOnYou: true, task: 'Fixture task', lastActivity: '2026-10-10T00:00:00Z', lastAssistantText: 'Input needed: fixture', registered: null };
// While the board answers, agent1 has one conversation with one message. A failure or refusal answers every path. // While the board answers, agent1 has one conversation with one message. A failure answers every path.
const catalogue = { conversations: [{ project: 'proj', seat: 'agent1', conversation: 'c1', availability: 'available', title: 'Fixture', conversationCreatedAt: '2026-10-10T00:00:00Z', lastActivityAt: '2026-10-10T00:00:00Z' }] }; const catalogue = { conversations: [{ project: 'proj', seat: 'agent1', conversation: 'c1', availability: 'available', title: 'Fixture', conversationCreatedAt: '2026-10-10T00:00:00Z', lastActivityAt: '2026-10-10T00:00:00Z' }] };
const page = { page: { branch: 'b1', entries: [{ message: 'm1', role: 'assistant', createdAt: '2026-10-10T00:00:00Z', content: [{ block: 0, type: 'text', text: 'Fixture history line' }] }] }, view: { incomplete: false, defaultBranch: 'b1', branch: 'b1' }, cursor: null, follow: null }; const page = { page: { branch: 'b1', entries: [{ message: 'm1', role: 'assistant', createdAt: '2026-10-10T00:00:00Z', content: [{ block: 0, type: 'text', text: 'Fixture history line' }] }] }, view: { incomplete: false, defaultBranch: 'b1', branch: 'b1' }, cursor: null, follow: null };
let gate = null; const answer = () => {
const server = createServer(async (req, res) => { if (mode === 'fail') throw new Error('board scan failed');
for await (const chunk of req) { void chunk; } if (mode === 'unavailable') throw new ReadError('read-failed', 'board unavailable');
await gate; 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() };
const path = new URL(req.url, 'http://localhost').pathname; if (mode === 'registered') return { sessions: [{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], counts: { waiting: 1 }, generatedAt: new Date().toISOString() };
const body = reply.status !== 200 ? reply.body : path === '/api/conversations' ? catalogue : path === '/api/conversation' ? page : reply.body; return { sessions: [], counts: {}, generatedAt: new Date().toISOString() };
res.writeHead(reply.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
}); const set = next => () => { mode = next; };
return { return {
server, board: {
// Holds every reply until the returned function is called. // 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(); }; }, hold: () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; },
empty: () => { reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } }; }, empty: set('empty'),
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() } }; }, one: set('one'),
fail: () => { reply = { status: 500, body: { error: 'board scan failed' } }; }, fail: set('fail'),
refuse: () => { reply = { status: 403, body: { error: 'board-refused' } }; },
// One seat a reply can reach: registered, alive, with a tmux session. // 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() } }; }, registered: set('registered'),
unavailable: () => { reply = { status: 503, body: { error: 'board unavailable' } }; }, 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 () => { 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 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 base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
const out = process.env.WEBUI_EVIDENCE; const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true }); 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'), '"◐" / ""'); assert.equal(await b.evaluate('getComputedStyle(document.querySelector("table.sessions .st-waiting"),"::before").content'), '"◐" / ""');
brd.fail(); await refresh(); brd.fail(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err'); 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.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 2);
assert.match(await text('#fresh-board'), /stale: the last refresh failed/); assert.match(await text('#fresh-board'), /stale: the last refresh failed/);
await flat(); await shot('board-stale-400'); await flat(); await shot('board-stale-400');
// Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from // A failed first read on a fresh load: the board is drawn empty and the footer says what the banner says.
// them: the open inspector, the project filter, the project tree's counts and the footer. // With nothing focused, focus stays put. (Before row 198 this followed a refusal; the refusal is gone.)
await b.evaluate('document.querySelector("#projects [data-project=proj]").click()'); brd.unavailable(); await b.navigate(base); await wait('!document.querySelector("#error").hidden');
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.
await b.evaluate('document.activeElement.blur()'); 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.activeElement === document.body'), true, 'a failure with nothing focused leaves focus alone');
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err'); 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.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); 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); 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('#fresh-board'), 'Board not read: the read failed');
assert.equal(await text('#status'), '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.'); 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(); brd.one(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true); assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=\'\']").getAttribute("aria-pressed")'), '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. // A good read after a failed one drops the stale words.
brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/); 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/); 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 // A conversation is opened from a registered session, and a failed catalogue read clears the picker.
// session is in no scan the page holds.
brd.registered(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===1'); 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 b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")'); 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'); 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()'); brd.registered(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")'); await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
brd.refuse(); await refresh(); // Back to the board, closed as the refusal used to close it.
assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true); await b.evaluate('document.getElementById("conv-back").click()'); await wait('document.querySelector("#conversation").hidden');
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 <body>.
assert.equal(await b.evaluate('document.activeElement.id'), 'main', 'focus after a refusal closes the conversation');
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2'); 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. // 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.ok(scrollH > innerH, `the board page is taller than ${innerH}px`);
assert.deepEqual([tree[0], tree[1]], [bar[1], foot[0]]); assert.deepEqual([tree[0], tree[1]], [bar[1], foot[0]]);
assert.deepEqual(await b.evaluate('window.errors'), []); 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 // 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 // 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. // with rows, then made to fail, then refused, at 400px.
test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }, async () => { 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 s = stubBus();
const at = new Date().toISOString(), id = 'd'.repeat(32); 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.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.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.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 }]; 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 base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
const out = process.env.WEBUI_EVIDENCE; const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true }); 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.errors'), []);
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []); 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); }
}); });
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { request, createServer } from 'node:http'; import { request } from 'node:http';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs'; import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
import { tmp } from '../../cli/tests/helpers.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 => { test('main listener refuses x-mosaic-tester before any board or bus read for every Host', async t => {
let hits = 0; let hits = 0;
const upstream = createServer((q, r) => { hits++; r.end('{}'); }); // The board runs in-process (row 198): a read that reaches it counts as a hit.
await new Promise(r => upstream.listen(0, '127.0.0.1', r)); t.after(() => close(upstream)); const board = { index: async () => { hits++; return { sessions: [] }; } };
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 []; } } }); 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)); t.after(() => close(web));
for (const host of [`127.0.0.1:${web.address().port}`, 'console.example', 'other.example']) for (const host of [`127.0.0.1:${web.address().port}`, 'console.example', 'other.example'])
for (const path of ['/api/board', '/api/bus/agents']) { for (const path of ['/api/board', '/api/bus/agents']) {