row 198 r4 round 2: UNTESTED, mos-1513 B1 via named listener, N1 refusal stub, canSend gate

Untested: no node or browser run. Pushed early at Sage's and Webby's request for a fixed sha.

- six old browser tests forward writes through tests/board-route.mjs (routeWrites), with /api/config answered send:true
- discord 409 reached through the named send listener
- board-refused.test.mjs (N1 stub), board-route-map.test.mjs (node mapping), send-off-browser.test.mjs (canSend gate, send off)
- app.js: reply form and Seen button gated on /api/config send
- BUILD.md round 2 section: numstat, line maps, N1 mapping, open items
This commit is contained in:
jason.woltje committed 2026-10-23 14:39:17 -05:00
1 parent 4d8123b0a5
commit ab64174753
12 files changed
+389 -13

No files matched your search

+99
View File
@@ -367,3 +367,102 @@ paths. Expected: `R6 G4b` fails at the granted 403 assertion, because the grante
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.
## r4 round 2: mos-1513 (B1 fix), N1 test, route helper (19:23Z 10-23)
Not run. Node and browser suites wait for Sage's CLEAR.
### B1: CDP interception instead of a named listener on the old files
Per mos-1513, the browser tests keep the real in-process board. Each one calls `routeWrites(b, base, board)` from
`packages/webui/tests/board-route.mjs` before it navigates. Fetch intercepts the page's `/api/config` (answered
with the real body and `send: true`), `/api/reply` and `/api/seen`. Each write is forwarded over node http to a
named send listener on the same board, with the preview Host, the `https://` Origin and the signed user header.
So the server's own gate, parse and status mapping answer, and the page gets that answer back. Nothing re-implements
`boardSend`.
Shared helper, named for webby-0243: `board-route.mjs` exports `ROUTE`, `namedListener(board)`, `forward(port, path,
body)` and `routeWrites(b, base, board)`. `namedListener` and `forward` are the same code `routeWrites` uses.
### Numstat (git diff --numstat, packages/webui/tests, against 4d8123b0a)
3 1 board-held.test.mjs
7 2 browser-edge.test.mjs
6 2 browser.test.mjs
6 3 discord.test.mjs
4 2 history-return-flow.test.mjs
4 2 return-flow.test.mjs
New files: `board-route.mjs` (helper), `board-route-map.test.mjs` (node, see below), `board-refused.test.mjs` (N1).
### Line map: every removed line in the six old tests
No assertion line is changed. Every removed line is one of these, and each is listed by old line:
- board-held.test.mjs: `done` teardown (adds `stopRoute()` before `b.close()`); the `routeWrites` line is added in
`setup`. No assertion.
- browser-edge.test.mjs: the `await b.viewport(320); await b.navigate(...)` line is rewritten as the same two
statements with the `routeWrites` line added before it; the `finally` adds `stopRoute?.()`. The `board` object
is unchanged. `held` and `releaseBoard` are the same. No assertion.
- browser.test.mjs: the `finally` adds `stopRoute?.()`; `routeWrites` is added before the `viewport`/`navigate`
line, which is byte-identical. No assertion.
- discord.test.mjs: the `fetch(base + '/api/reply', ...)` request line becomes `namedListener` + `forward`, the
request setup, not the assertion; `assert.equal(reply.status, 409)` is byte-identical. The `finally` closes the
named listener.
- history-return-flow.test.mjs: `let b` gains `stopRoute`; `finally` adds `stopRoute()`. No assertion.
- return-flow.test.mjs: `let web, b` gains `stopRoute`; `routeWrites` added after `browser()`; `finally` adds
`stopRoute()`. No assertion.
### N1 (sage-3618, mos-1506 b): the refusal block, old to new
Old line numbers are in `shell.test.mjs` at 14fe7513c, the block 178-245. New line numbers are in
`board-refused.test.mjs`. Every old assertion is restored with its text unchanged:
181 -> 74 183 -> 76 184 -> 77 185 -> 78 187 -> 80
188 -> 81 189 -> 82 190 -> 83 191 -> 84 192 -> 85
193 -> 86 194 -> 87 195 -> 88 196 -> 89 197 -> 90
203 -> 97 204 -> 98 205 -> 99 206 -> 100 207 -> 101
208 -> 102 209 -> 103 210 -> 104 211 -> 105 212 -> 106
215 -> 109 216 -> 110 217 -> 111 218 -> 112 220 -> 114
221 -> 115 227 -> 122 229 -> 124 232 -> 127 236 -> 131
237 -> 132 238 -> 133 239 -> 134 240 -> 135 241 -> 136
243 -> 138
Changes in the port: the page-level 403 stub (`Fetch` on `*/api/board*` and `*/api/conversation*`) is the one
place a page-level stub is used, per N1. The board is the in-process one with `refuse()` set. The fake-board
data in `board()` is the old fake board's data, moved.
Pinned N1 mutant (not run): in `packages/webui/public/app.js` (the `error(err)` branch that sets
`refused = err.status === 403`), drop the `#board-refused` show and the `refused` banner. Expected:
`board-refused.test.mjs` fails at line 80 (`banner ref`) and line 83 (`#board-refused` hidden). Run after CLEAR.
### Node coverage for the real route
`board-route-map.test.mjs` (new) starts the named listener on a fixture board and pins the mapping the browser
tests now rely on: seen is 200; a delivered reply is 200 with `delivered: true`; an exit-4 reply is 200 with
`delivered: false` and `exitCode: 4` (this is `replyWith` in control-board/src/serve.mjs, which maps a nonzero exit
to 200, not a status code). Existing coverage cited, not duplicated: `board-inprocess.test.mjs:84-97` (seen 200 and
reply delivered through a named listener); `send.test.mjs:114-119` (loopback Host writes 403 read-only, nothing
reaches the board); `preview.test.mjs:185-199` (every write 403 read-only from the public name and loopback; 401
unsigned on `/api/reply`).
The discord 409 is now reached through the named listener, so `discord.test.mjs` pins the connector refusal on the
real route.
### Not done in this round
- The plain-listener browser check for the reply form and Seen line with send off (the `canSend` gate in app.js).
The node and browser coverage for that gate is not written yet.
- `send-browser.test.mjs:67,243` is unchanged. Webby to confirm next round.
- Mutant M18 and the N1 mutant are not run.
### Gate status
UNVERIFIED. Nothing in this round is run. Waiting for Sage's CLEAR before any node or browser run.
### Correction to the r4 round 2 entry above (19:24Z 10-23)
- The N1 mutant's file is `packages/webui/src/public/app.js`, not `packages/webui/public/app.js`.
- `board-refused.test.mjs` does not use the in-process board. Its `board()` is an inline stub object with a `refuse()`
flag; the 403 comes from a page-level Fetch stub on `*/api/board*` and `*/api/conversation*`, as the N1 ruling
allows. The "in-process board with refuse() set" sentence above is wrong.
+6 -3
View File
@@ -20,6 +20,8 @@
// `sign-in required`; that signs the page out too, and the line links to the Console's sign-in.
// Any other 401 stays an error. `sso` comes from /api/config.
let sso = false;
// Row 198 (mos-1512): the reply form and the Seen button draw only when /api/config says send; otherwise a line says so.
let canSend = false;
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
@@ -46,7 +48,7 @@
const s = Date.parse(sent), a = Date.parse(r.lastActivity);
return Number.isFinite(s) && Number.isFinite(a) && a < s;
}
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const canReply = r => canSend && !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const row = id => data?.sessions.find(r => key(r) === id);
const announce = text => { $('announce').textContent = text; };
// Colours come from tokens.css by data-palette and data-mode. System removes data-mode, so the OS setting picks light or dark.
@@ -66,6 +68,7 @@
$('mode').onchange = e => { mode = e.target.value; theme(); };
function seenButton(r) {
if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return '';
if (!canSend) return '<span class="small muted">Seen is off: this Console is read-only.</span>';
return `<button type="button" class="btn" data-seen="${esc(key(r))}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
}
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
@@ -94,7 +97,7 @@
if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]);
if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]);
const receipt = receipts.get(selected);
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${historyButton(r)}${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${historyButton(r)}${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${!canSend ? 'Replies are off: this Console is read-only.' : r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
}
// Conversation view (#1507, CHAT-02): the full read-only history from the board's
// /api/conversations and /api/conversation routes. Session content is set with
@@ -476,5 +479,5 @@
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
});
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
api('/api/config').then(config => { board = config.board || 'the Console board'; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
api('/api/config').then(config => { canSend = config.send === true; board = config.board || 'the Console board'; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
})();
+3 -1
View File
@@ -22,6 +22,7 @@ import { views } from '../../bus/src/views.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { fixture, close } from './fixture.mjs';
import { routeWrites } from './board-route.mjs';
const businesses = {
demo: {
@@ -73,6 +74,7 @@ async function setup(machine, fast = false) {
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.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
const stopRoute = await routeWrites(b, base, f.board);
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)})})()`);
@@ -81,7 +83,7 @@ async function setup(machine, fast = false) {
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 }); };
const done = async () => { await stopRoute(); await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
return { b, base, wait, text, shown, reqs, polls, settle, done };
}
+142
View File
@@ -0,0 +1,142 @@
// Row 198 r4 (sage-3618, mos-1506): the board refusal. The in-process board never answers 403 (consoleRead
// has no 403 code), so the browser stubs the answer: while the board is refused, Fetch fulfils /api/board
// and /api/conversation(s) with 403 board-refused, as the old fake board server did. The assertions are the
// refusal block that 4d8123b0a deleted from shell.test.mjs, ported one for one (BUILD.md, r4 mapping).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync } 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 { ReadError } from '../src/reads.mjs';
// The old fake board's data: agent1 waiting on you in proj, plus agent2 and agent3, one conversation.
function board() {
const state = { refused: false, down: false, mode: 'ok' };
let index = { sessions: [], counts: {}, generatedAt: new Date().toISOString() };
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 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 set = (sessions, counts) => { index = { sessions, counts, generatedAt: new Date().toISOString() }; state.mode = 'ok'; };
return {
state,
board: {
index: () => {
if (state.down) throw new ReadError('read-failed', 'board unavailable');
if (state.mode === 'fail') throw new Error('board scan failed');
return index;
},
conversation: url => {
if (state.mode === 'fail') throw new Error('catalogue read failed');
return { status: 200, body: url.pathname === '/api/conversations' ? catalogue : page };
},
markSeen: () => ({ status: 200, body: { ok: true } }),
reply: () => ({ status: 200, body: { delivered: true } }),
},
one: () => { state.refused = false; state.down = false; set([one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], { waiting: 1, working: 1 }); },
registered: () => { state.refused = false; state.down = false; set([{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], { waiting: 1 }); },
fail: () => { state.refused = false; state.mode = 'fail'; },
unavailable: () => { state.refused = false; state.down = true; },
refuse: () => { state.refused = true; },
};
}
test('a board refusal fails closed: the page hides, the banner says refused, the conversation closes and clears', { timeout: 180000 }, async () => {
const brd = board(); brd.one();
const web = await startServer({ port: 0, board: brd.board });
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 });
const shot = async name => { if (out) await b.screenshot(join(out, `shell-${name}.png`)); };
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 count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`);
const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px');
const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); };
// The stub: while refused, the board's reads get the 403 the old fake server gave; otherwise they go on to the Console.
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*/api/board*', requestStage: 'Request' }, { urlPattern: '*/api/conversation*', requestStage: 'Request' }] });
b.on('Fetch.requestPaused', ({ requestId }) => {
const answer = brd.state.refused
? b.call('Fetch.fulfillRequest', { requestId, responseCode: 403, responseHeaders: [{ name: 'content-type', value: 'application/json' }], body: Buffer.from(JSON.stringify({ error: 'board-refused' })).toString('base64') })
: b.call('Fetch.continueRequest', { requestId });
answer.catch(() => {});
});
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
await b.viewport(400, 900);
await b.navigate(base); await wait('!document.querySelector("#sessions [aria-busy]")');
await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
// 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.
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.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);
assert.equal(await text('#footer'), '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('#projects'), 'Not read: the read failed.');
await flat(); await shot('board-refused-then-failed-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');
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await text('#footer'), 'waiting 1 · working 1');
// 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.
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")');
assert.equal(await b.evaluate('document.querySelector("#conv-form").hidden'), false);
// Fix to HEAD behaviour: a failed catalogue read leaves no session from the last one in the picker.
assert.equal(await count('#conv-pick option'), 1);
brd.fail(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("History unavailable")');
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 <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');
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { await b.close(); await close(web); }
});
@@ -0,0 +1,32 @@
// Row 198 (mos-1513): the write mapping the browser tests reach through board-route.mjs, pinned in node
// against the named send listener itself. The Console's own gate, parse and status mapping answer here;
// the browser tests only get back what this listener returns.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { close, fixture } from './fixture.mjs';
import { forward, namedListener } from './board-route.mjs';
test('named send listener maps the board writes: seen is 200, a delivered reply is 200 delivered, an exit-4 reply is 200 not delivered', { timeout: 60000 }, async t => {
const f = await fixture();
const named = await namedListener(f.board);
t.after(async () => { await close(named); await f.close(); });
const port = named.address().port;
const rec = (await f.board.index()).sessions.find(r => r.agent === 'agent1');
const row = `${rec.project}/${rec.agent}`;
const seen = await forward(port, '/api/seen', JSON.stringify({ project: rec.project, agent: rec.agent, lastActivity: rec.lastActivity, seen: true }));
assert.equal(seen.status, 200, seen.text);
const delivered = await forward(port, '/api/reply', JSON.stringify({ agent: row, text: 'hi' }));
assert.equal(delivered.status, 200, delivered.text);
assert.equal(JSON.parse(delivered.text).delivered, true);
assert.equal(f.captures.length, 1);
f.failReply();
const refused = await forward(port, '/api/reply', JSON.stringify({ agent: row, text: 'hi' }));
assert.equal(refused.status, 200, refused.text);
const body = JSON.parse(refused.text);
assert.equal(body.delivered, false);
assert.equal(body.exitCode, 4);
assert.equal(f.captures.length, 2);
});
+58
View File
@@ -0,0 +1,58 @@
// Row 198 (mos-1513): the browser tests reach the real board routes. Fetch intercepts the page's
// /api/config (send on) and its /api/reply and /api/seen writes. Node forwards each write to a named
// send listener on the same board, with the preview Host, the https Origin and the signed user header,
// so the server's own gate, parse and status mapping answer and the page gets that answer back.
// Nothing here re-implements boardSend; the node route tests pin what the listener returns.
import { request } from 'node:http';
import { startServer } from '../src/serve.mjs';
import { close } from './fixture.mjs';
export const ROUTE = { host: 'board-route.example', header: 'X-authentik-uid', user: 'board-route-user' };
const JSON_HEADER = [{ name: 'content-type', value: 'application/json' }];
// The named send listener on the board: the preview Host, so the server's own gate and status mapping answer.
export function namedListener(board) {
return startServer({ port: 0, board, preview: ROUTE, send: async () => ({ id: 'board-route' }) });
}
// One POST to the named listener, as the browser's forwarded write would arrive. Resolves { status, text }.
export function forward(port, path, body) {
return new Promise((resolve, reject) => {
const headers = { host: ROUTE.host, origin: `https://${ROUTE.host}`, [ROUTE.header]: ROUTE.user, 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) };
const req = request({ host: '127.0.0.1', port, method: 'POST', path, headers }, res => {
let text = '';
res.setEncoding('utf8');
res.on('data', chunk => { text += chunk; });
res.on('end', () => resolve({ status: res.statusCode, text }));
});
req.on('error', reject);
req.end(body);
});
}
// Returns a function that closes the named listener. Call it in the test's finally, before the browser closes.
export async function routeWrites(b, base, board) {
const listener = await namedListener(board);
const port = listener.address().port;
await b.call('Fetch.enable', { patterns: [
{ urlPattern: '*/api/config', requestStage: 'Request' },
{ urlPattern: '*/api/reply', requestStage: 'Request' },
{ urlPattern: '*/api/seen', requestStage: 'Request' },
] });
b.on('Fetch.requestPaused', async ({ requestId, request: req }) => {
try {
const path = new URL(req.url).pathname;
let answer;
if (path === '/api/config') {
const config = await (await fetch(base + '/api/config')).json();
answer = { status: 200, text: JSON.stringify({ ...config, send: true }) };
} else {
answer = await forward(port, path, req.postData ?? '');
}
await b.call('Fetch.fulfillRequest', { requestId, responseCode: answer.status, responseHeaders: JSON_HEADER, body: Buffer.from(answer.text).toString('base64') });
} catch {
await b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {});
}
});
return () => close(listener);
}
+5 -1
View File
@@ -2,6 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { routeWrites } from './board-route.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 () => {
@@ -19,9 +20,12 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
};
const web = await startServer({ port: 0, board });
const b = await browser();
const base = `http://127.0.0.1:${web.address().port}`;
let stopRoute;
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 {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'Object.defineProperty(window,"localStorage",{get(){throw new Error("storage denied")}});window.errors=[];addEventListener("error",e=>errors.push(e.message));' });
stopRoute = await routeWrites(b, base, board);
await b.viewport(320); await b.navigate(`http://127.0.0.1:${web.address().port}`);
// Row 95: with storage denied the toggle starts off and still turns on; the page throws nothing.
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Machine sessions hidden/);
@@ -93,5 +97,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 { releaseBoard?.(index); held?.({ status: 200, body: {} }); await b.close(); await close(web); }
} finally { releaseBoard?.(index); held?.({ status: 200, body: {} }); await stopRoute?.(); await b.close(); await close(web); }
});
+4 -1
View File
@@ -4,14 +4,17 @@ import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { fixture } from './fixture.mjs';
import { routeWrites } from './board-route.mjs';
test('served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures', { timeout: 120000 }, async () => {
const f = await fixture(), b = await browser();
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
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')})()`);
let stopRoute;
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
stopRoute = await routeWrites(b, f.base, f.board);
await b.viewport(1440); await b.navigate(f.base); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '2');
assert.equal(await b.evaluate('document.querySelectorAll("#projects [data-project]").length'), 3);
@@ -119,5 +122,5 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.deepEqual(await b.evaluate('window.errors'), []);
await b.navigate(f.base); await wait('!document.querySelector("#error").hidden');
assert.match(await b.evaluate('document.querySelector("#error").textContent'), /No board data loaded/);
} finally { await b.close(); await f.close(); }
} finally { await stopRoute?.(); await b.close(); await f.close(); }
});
+6 -3
View File
@@ -8,10 +8,11 @@ import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { close, inProcessBoard } from './fixture.mjs';
import { forward, namedListener } from './board-route.mjs';
test('Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content', { timeout: 60000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-discord-'));
let web, b;
let web, b, named;
try {
const dir = join(root, 'discord', 'pilot'); mkdirSync(join(dir, 'run.lock'), { recursive: true });
writeFileSync(join(root, 'discord', 'pilot.json'), JSON.stringify({ name: 'pilot', seat: 'sage', tokenFile: '/PRIVATE-NOT-READ', guildId: 'PRIVATE-ID' }), { mode: 0o600 });
@@ -30,7 +31,9 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply
assert.equal(JSON.stringify(payload).includes('PRIVATE'), false);
assert.equal(payload.sessions[0].task, 'Discord connector'); assert.equal(payload.sessions[0].taskSource, 'connector'); assert.equal(payload.sessions[0].taskSetBy, null);
assert.equal(JSON.stringify(payload).includes('forged'), false);
const reply = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/sage (discord: pilot)', text: 'refuse me' }) });
// The 409 is the board's refusal, reached through the named send listener; the loopback listener has no send and answers 403 first.
named = await namedListener(board);
const reply = await forward(named.address().port, '/api/reply', JSON.stringify({ agent: 'fleet/sage (discord: pilot)', text: 'refuse me' }));
assert.equal(reply.status, 409);
b = await browser(); await b.viewport(320, 1000);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
@@ -57,5 +60,5 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply
await wait('document.querySelector("#inspection").textContent.includes("owner absent")');
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /not braked; owner absent/);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /offline/);
} finally { if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
} finally { if (b) await b.close(); if (named) await close(named); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
});
@@ -8,6 +8,7 @@ import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { routeWrites } from './board-route.mjs';
import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs';
const PEER = '[from: darkwing (7525c8e4) -> to: fixture (00000000)] peer note that lands mid-turn';
@@ -16,7 +17,7 @@ const BIG = 'BIG_START ' + Array.from({ length: 900000 }, (_, i) => (i % 1679616
test('return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch', { timeout: 240000 }, async () => {
const f = await historyFixture();
let b;
let b, stopRoute;
try {
const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl');
const log = session(file, f.projectRoot);
@@ -24,6 +25,7 @@ test('return flow through the conversation view: send, tool call, delayed result
log.add(assistant('Input needed: OLD_ANSWER'), at(-90));
b = await browser(); await b.viewport(1440, 1000);
stopRoute = await routeWrites(b, f.base, f.board);
const wait = waiter(b);
const draft = () => b.evaluate('(() => { const t = document.querySelector("#conv-reply"); return [t.value, t.selectionStart, document.activeElement === t]; })()');
// The seat is waiting on Jason, so the view opens from its card.
@@ -95,5 +97,5 @@ test('return flow through the conversation view: send, tool call, delayed result
assert.notEqual(await b.evaluate('document.querySelector("#conv-pick").value'), opened);
assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null);
assert.equal(await b.evaluate('document.querySelector("#conv-reply").value'), 'NEXT_DRAFT');
} finally { if (b) await b.close(); await f.close(); }
} finally { if (stopRoute) await stopRoute(); if (b) await b.close(); await f.close(); }
});
+4 -2
View File
@@ -14,6 +14,7 @@ import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { close, inProcessBoard } from './fixture.mjs';
import { routeWrites } from './board-route.mjs';
const at = s => new Date(Date.now() + s * 1000).toISOString();
const message = (timestamp, m) => JSON.stringify({ type: 'message', timestamp, message: m }) + '\n';
@@ -21,7 +22,7 @@ const assistant = (timestamp, text) => message(timestamp, { role: 'assistant', s
test('reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh', { timeout: 90000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-return-'));
let web, b;
let web, b, stopRoute;
try {
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
const file = join(sessionsDir, 's.jsonl');
@@ -32,6 +33,7 @@ test('reported return flow and relative Age: reply sent from the inspector, then
exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
web = await startServer({ port: 0, board });
b = await browser(); await b.viewport(1440, 1000);
stopRoute = await routeWrites(b, `http://127.0.0.1:${web.address().port}`, board);
const wait = (expression, ms = 20000) => b.evaluate(`(async()=>{for(const end=Date.now()+${ms};Date.now()<end;){if(${expression})return true;await new Promise(r=>setTimeout(r,100))}throw Error('timeout: '+${JSON.stringify(expression)})})()`);
const inspection = () => b.evaluate('document.querySelector("#inspection").textContent');
await b.navigate(`http://127.0.0.1:${web.address().port}`); await wait('document.querySelector("table.sessions [data-open]")');
@@ -71,5 +73,5 @@ test('reported return flow and relative Age: reply sent from the inspector, then
const later = await inspection();
assert.doesNotMatch(later, /Waiting for a reply|before your message/);
assert.match(later, /Last assistant text/);
} finally { if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
} finally { if (stopRoute) await stopRoute(); if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
});
@@ -0,0 +1,26 @@
// Row 198 (mos-1512): with send off, the Console is read-only in the page. The reply form and the Seen
// button are not drawn, and the inspector says so in words. The Console here is a plain listener with no
// send, so /api/config answers send: false and the page's gate is what is under test.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { browser } from './browser.mjs';
import { fixture } from './fixture.mjs';
test('send off: the reply form and the Seen button are not drawn, and the inspector says the Console is read-only', { timeout: 60000 }, async () => {
const f = await fixture(), 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: '+${JSON.stringify(expr)})})()`);
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
await b.viewport(1440); await b.navigate(f.base);
await wait('document.querySelector("table.sessions [data-open=\'proj/agent1\']")');
assert.equal(await b.evaluate('(await fetch("/api/config").then(r => r.json())).send'), false);
await b.evaluate('document.querySelector("table.sessions [data-open=\'proj/agent1\']").click()');
await wait('!document.querySelector("#inspector").hidden');
await wait('document.querySelector("#inspection").textContent.includes("Replies are off: this Console is read-only.")');
assert.equal(await b.evaluate('!!document.querySelector(".reply-form")'), false);
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), false);
assert.equal(await b.evaluate('!!document.querySelector("#inspection [data-seen]")'), false);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Seen is off: this Console is read-only\./);
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { await b.close(); await f.close(); }
});