Files
stack/packages/webui/tests/bus-browser.test.mjs
T
jason.woltjeandClaude Opus 5.5 08b428ecf1 feat(webui,conversation): S5 WebUI views and CHAT-03 follow-ups (row 40, #1522)
Dewey's round 3 candidate, manifest
agents/dewey/work/queue-40/candidate-manifest-r3.sha256 (d0aa0ded,
27 files, checked OK in the canonical tree).

- WebUI inbox, tasks, agents and trail views, read-only over /api/bus.
  The README says the bus proof ends at the Console process.
- CHAT-03 seal: the engine command is fixed, the engine environment is
  explicit, SEAL_FLAGS has --no-approve, escalating is cleared on throw.
- Terminal input typed after Ctrl-T or Ctrl-O is held. Only the run whose
  own parse set held drains it (T1), and #run catches errors per action.
- DEFERRED keeps N2 and moves F2 to done, citing T1.

Reviews: Filbert approve (comment 27011, rev 260), Darkwing approve
(27013, rev 264). Landing gate on 8cad7722 plus the candidate: webui 22,
conversation 161, control-board 124, every scripts/test-*.sh green,
test-task 98/0. Mutant Mr survives; its flows test is the first
follow-up row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 22:05:43 -05:00

267 lines
21 KiB
JavaScript

// Slice 1 S5 (#1522): the inbox, tasks, agents and trail pages in a browser,
// against an in-process broker read through its reader session, so every row
// the page renders has the broker's own shape. Nothing here starts the human
// CLI or reaches a real broker; the seeding writes go straight to the broker.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { views } from '../../bus/src/views.mjs';
import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs';
import { browser } from './browser.mjs';
import { fixture, close } from './fixture.mjs';
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> \u001b[31mred \u202eevil';
const businesses = {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
roles: {
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
},
},
};
const digest = c => c.repeat(64);
const iso = () => new Date().toISOString();
// A poll read counts only when it was sent after the stack's own write (task_current), and the
// broker's clock can run a few milliseconds ahead of Date.now() when writes come fast.
const later = () => new Date(Date.now() + 1000).toISOString();
function seed(root) {
const store = new Store(root), b = new Broker({ store, businesses });
const launch = (role, harness, address) => { const cap = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness, address }); b.request(cap, { verb: 'role.claim' }); return cap; };
const coder = launch('coder', 'pi', 'coder-run'), pm = launch('pm', 'pi', 'pm-run');
launch('cto', 'claude-code', 'cto-thread');
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
// The human's request, the task PM created for it, a later poll that moved it, and a refused write.
const asked = b.request(human, { verb: 'message.send', args: { to: 'pm', body: `Build the inbox ${HOSTILE}` } });
const fields = { project_id: 32, title: `Inbox ${HOSTILE}`, description: `Line one ${HOSTILE}\nline two`, done: false, due_date: '2026-10-20T00:00:00Z', priority: 3, percent_done: 40, bucket: 2, labels: [], assignees: [5] };
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/7', updated: iso(), digest: digest('a'), fields }], events: [{ kind: 'task.created', subject: 'vikunja:32/7', body: { request: asked.request, requirement: 'REQ-SLICE-1' } }] });
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/7', updated: iso(), digest: digest('b'), fields: { ...fields, bucket: 3 }, via: 'board', read_at: later() }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/7', body: { via: 'board', digest: digest('b'), previous: digest('a'), changed: ['bucket'], comments: 2 } }] });
b.recordTask({ cap: pm, business: 'demo', events: [{ kind: 'task.conflict', subject: 'vikunja:32/7', body: { verb: 'task.update', expected: digest('a'), actual: digest('b') } }] });
// A task the stack never wrote, a done task and one no longer readable.
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/8', updated: iso(), digest: digest('c'), fields: { ...fields, title: 'Foreign task', bucket: 2, assignees: [] }, via: 'cursor', read_at: iso() }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/8', body: { via: 'cursor', digest: digest('c'), previous: null, changed: [] } }] });
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/9', updated: iso(), digest: digest('d'), fields: { ...fields, title: 'Done task', done: true, percent_done: 100 } }] });
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/10', updated: iso(), digest: digest('e'), fields: { ...fields, title: 'Moved task' } }] });
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/10', updated: iso(), digest: digest('f'), fields: { gone: 'moved' }, via: 'task', read_at: later() }], events: [{ kind: 'task.missing', subject: 'vikunja:32/10', body: { reason: 'moved', project: 40 } }] });
// An open blocking decision that replaced an earlier one, seen by the human; one routed to PM; one resolved.
const raise = args => b.request(coder, { verb: 'decision.raise', args });
const push = { action: 'git.push.protected', target: 'refactor', task_ref: 'vikunja:32/7', options: [{ key: 'yes', text: `Allow ${HOSTILE}` }, { key: 'no', text: 'Decline' }], recommendation: 'no', blocking: true };
const old = raise({ ...push, question: 'Push the first candidate?' });
const open = raise({ ...push, question: `Push the release? ${HOSTILE}\nSecond line`, supersedes: old.id });
b.request(human, { verb: 'decision.seen', args: { id: open.id } });
const routed = raise({ action: 'task.scope.change', question: 'Widen the scope?', options: [{ key: 'widen', text: 'Widen' }, { key: 'keep', text: 'Keep' }], recommendation: 'keep', blocking: false, approvalChoice: 'widen' });
const resolved = raise({ ...push, question: 'Push the hotfix?', blocking: false, task_ref: undefined, target: 'hotfix' });
b.request(human, { verb: 'decision.resolve', args: { id: resolved.id, choice: 'yes' } });
const reader = b.bindReader({ business: 'demo' });
const calls = [];
let fail = null;
const bus = views({ call: async (verb, args = {}) => { calls.push(verb); if (fail) throw new BusReadError(fail); return structuredClone(b.request(reader, { verb, args })); } });
return { store, bus, calls, fail: code => { fail = code; }, ids: { old: old.id, open: open.id, routed: routed.id, resolved: resolved.id } };
}
test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes', { timeout: 180000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-bus-browser-'));
const s = seed(root), f = await fixture(), b = await browser();
const web = await startServer({ port: 0, board: f.boardURL, bus: s.bus }), base = `http://127.0.0.1:${web.address().port}/`;
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: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); };
const inertCheck = async where => {
assert.equal(await b.evaluate('!!window.injected'), false, where);
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view script, #bus-view img, #bus-view iframe, #bus-view object, #bus-view embed, #bus-view svg, #bus-view style, #bus-view link").length'), 0, where);
const shown = await text('#bus-view');
assert.doesNotMatch(shown, /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/, where);
};
const noOverflow = async where => {
for (const width of [320, 1440]) {
await b.viewport(width, 1000);
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, `${where} ${width} overflow`);
if (out) await b.screenshot(join(out, `s5-${where}-${width}.png`));
}
await b.viewport(1440, 1000);
};
try {
// Every request the page makes is logged with its method; the bus pages must make only GETs.
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];const f=window.fetch;window.fetch=(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);return f(u,o)};' });
await b.viewport(1440, 1000);
// The board is untouched at the root, with the Console sections above its projects.
await b.navigate(base); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
assert.equal(await b.evaluate('document.querySelector("#bus-view").hidden'), true);
assert.equal(await b.evaluate('document.querySelectorAll("#projects [data-project]").length'), 3);
await wait('document.querySelector("#sections a[href=\\"#/inbox\\"] .count")?.textContent==="1"');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/');
// Inbox: the one open human decision, its approval context, and the gap labels.
await go('#/inbox', 'Inbox');
assert.equal(await b.evaluate('document.activeElement.tagName'), 'H1');
assert.equal(await b.evaluate('document.body.classList.contains("on-bus")'), true);
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-dec").length'), 1);
const card = await text('#bus-view .s1-dec');
assert.match(card, /Push the release\? <script>window\.injected=1<\/script>/);
assert.match(card, /␛\[31mred \[U\+202E\]evil/);
assert.doesNotMatch(card, /Second line/);
assert.match(card, /Choosing “yes” approves git\.push\.protected on refactor\./);
assert.match(card, /blocking/);
for (const label of ['Decisions routed to roles: not in the Q1 module', 'Closed decisions: not in the Q1 module', 'Seen and DM state per row: not in the Q1 module'])
assert.ok((await text('#bus-view')).includes(label), label);
assert.equal(await b.evaluate('document.title'), 'Inbox · Mosaic Console');
await inertCheck('inbox'); await noOverflow('inbox');
// The open decision: options with Copy, history, the earlier version, no write.
await b.evaluate('document.querySelector("#bus-view .s1-dec-open").click()');
await wait(`location.hash===${JSON.stringify(`#/inbox/${s.ids.open}`)}`);
await wait('document.querySelectorAll("#bus-view [data-copy]").length===2');
assert.equal(await b.evaluate('document.activeElement.tagName'), 'H1');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view [data-copy]")].map(e=>e.dataset.copy)'), [`mosaic decide ${s.ids.open} yes`, `mosaic decide ${s.ids.open} no`]);
const detail = await text('#bus-view');
assert.match(detail, /approves git\.push\.protected/); assert.match(detail, /recommended/);
assert.match(detail, /Seen by jason via cli/);
assert.match(detail, /Second line/);
assert.ok(detail.includes(`${s.ids.old.slice(0, 8)} withdrawn Push the first candidate?`), detail);
assert.ok(detail.includes('A later version that replaces this one: not in the Q1 module'));
assert.match(detail, /DMnot sent/);
await inertCheck('decision');
// Copy writes the command to the clipboard and nothing else; with no clipboard the command is selected.
await b.evaluate('window.copied=[];navigator.clipboard.writeText=async t=>{copied.push(t)}');
await b.evaluate('document.querySelector("#bus-view [data-copy]").click()');
await wait('document.querySelector("#copy-status").textContent.startsWith("Copied")');
assert.deepEqual(await b.evaluate('window.copied'), [`mosaic decide ${s.ids.open} yes`]);
await b.evaluate('navigator.clipboard.writeText=async()=>{throw new Error("denied")}');
await b.evaluate('document.querySelectorAll("#bus-view [data-copy]")[1].click()');
await wait('document.querySelector("#copy-status").textContent.startsWith("The clipboard is unavailable")');
assert.equal(await b.evaluate('getSelection().toString()'), `mosaic decide ${s.ids.open} no`);
// A refresh keeps the focused button and the copy status.
await b.evaluate('document.querySelectorAll("#bus-view [data-copy]")[1].focus()');
await b.evaluate('document.querySelector("#refresh").click()');
await new Promise(r => setTimeout(r, 500));
assert.equal(await b.evaluate('document.activeElement.dataset.copy'), `mosaic decide ${s.ids.open} no`);
assert.match(await text('#copy-status'), /clipboard is unavailable/);
await noOverflow('decision');
// A decision routed to a role and a resolved one show no command.
await go(`#/inbox/${s.ids.routed}`, 'Decision');
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
assert.match(await text('#bus-view'), /routed to pm, not to you/);
assert.match(await text('#bus-view'), /Choosing “widen” approves task\.scope\.change\./);
await go(`#/inbox/${s.ids.resolved}`, 'Decision');
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
assert.match(await text('#bus-status'), /Resolved: jason chose “yes” via cli/);
assert.match(await text('#bus-view'), /chosen/);
// Tasks: buckets, markers, done, missing, the freshness line.
await go('#/tasks', 'Tasks');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/tasks');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view h2[id^=b-bucket]")].map(h=>h.textContent)'), ['Bucket 2 1', 'Bucket 3 1']);
const tasks = await text('#bus-view');
assert.match(tasks, /Inbox <script>window\.injected=1<\/script>/);
// The list reads no trail: it says only that the current state came from a Vikunja read.
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view .badge")].filter(e=>/^last read from Vikunja \\((board|cursor)\\)$/.test(e.textContent)).length'), 2);
assert.doesNotMatch(tasks, /refused write|not created by the stack/);
assert.match(tasks, /#10 \(no title in the current read\)/);
assert.match(tasks, /user 5/); assert.match(tasks, /2026-10-20/); assert.match(tasks, /high/);
assert.match(tasks, /Done 1/); assert.match(tasks, /No longer readable 1/); assert.match(tasks, /missing: moved/);
assert.match(tasks, /Bucket names, bot names and the Vikunja address: not in the Q1 module/);
assert.match(tasks, /Poller status: from the S3 status read, not built yet/);
await inertCheck('tasks'); await noOverflow('tasks');
// Task detail: request, requirement, external change, refused write, decisions, snapshots and trail.
await go('#/tasks/vikunja%3A32%2F7', '#7');
const t7 = await text('#bus-view');
assert.match(t7, /RequirementREQ-SLICE-1/);
assert.match(t7, /Changed in Vikunja after the stack wrote it: bucket, 2 new comments/);
assert.match(t7, /task\.update: Vikunja changed after the stack read it \(expected aaaaaaaa, found bbbbbbbb\)/);
assert.ok(t7.includes(s.ids.open.slice(0, 8)) && t7.includes(s.ids.old.slice(0, 8)));
assert.match(t7, /Request from the human, recorded from the CLI/);
assert.match(t7, /the request task\.created names/);
assert.match(t7, /The human to pm: Build the inbox/);
assert.match(t7, /Created for REQ-SLICE-1/);
assert.match(t7, /Changed in Vikunja: bucket, and 2 new comments/);
assert.match(t7, /Session started: pm as pm-run \(pi, pm-run\)/);
assert.match(t7, /Snapshots 2/);
await inertCheck('task'); await noOverflow('task');
await go('#/tasks/vikunja%3A32%2F8', '#8');
assert.match(await text('#bus-view'), /Not created by the stack; first read/);
assert.match(await text('#bus-view'), /Read from Vikunja for the first time/);
await go('#/tasks/vikunja%3A32%2F10', '#10');
assert.match(await text('#bus-status'), /No longer readable in Vikunja \(moved, now in project 40\)/);
// The trail with filters.
await go('#/trail/task/vikunja%3A32%2F7?kind=task', 'Trail for task #7');
assert.equal(await b.evaluate('document.querySelector(".chips [aria-current=true]").textContent.startsWith("Task changes")'), true);
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view .s1-trow")].every(r=>r.classList.contains("s1-g-task"))'), true);
await go('#/trail/task/vikunja%3A32%2F7?kind=request', 'Trail for task #7');
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-trow").length'), 3); // the message, its delivery, the human.input
await go(`#/trail/decision/${s.ids.open}`, 'Trail for decision');
const dt = await text('#bus-view');
assert.match(dt, /Raised with this context: choosing “yes” approves git\.push\.protected on refactor/);
assert.match(dt, /Withdrawn by coder/); assert.match(dt, /jason saw it via cli/);
assert.match(dt, /Push the first candidate\?/);
await inertCheck('trail'); await noOverflow('trail');
// Agents: one row per held role, with its harness and address; the gaps are labelled.
await go('#/agents', 'Agents');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody th")].map(e=>e.textContent)').then(r => r.sort()), ['coder', 'cto', 'pm']);
assert.match(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody tr")].find(r=>r.querySelector("th").textContent==="cto").textContent'), /cto-runclaude-codecto-thread/);
for (const label of ['Role instances, launch limits and model families', 'Launch state', 'Live and ended sessions', 'Credential notices'])
assert.ok((await text('#bus-view')).includes(`${label}: not in the Q1 module`), label);
await noOverflow('agents');
// Addresses that are not pages.
await go('#/nope', 'Not found');
await go('#/inbox/bad%20id', 'Not found');
assert.match(await text('#bus-view'), /bad id is not a decision id/);
await go('#/tasks/vikunja%3A32%2F99', 'Not found');
// A failed read shows what was read before, labelled; a page never read shows the refusal.
await go('#/inbox', 'Inbox');
s.fail('outcome-unknown');
await b.evaluate('document.querySelector("#refresh").click()');
await wait('!!document.querySelector("#bus-view .s1-stale")');
assert.match(await text('#bus-view .s1-stale'), /The last read failed\. The bus did not answer in time\. \(outcome-unknown\)/);
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-dec").length'), 1);
await go('#/trail/task/vikunja%3A32%2F9', 'The read failed');
s.fail('human-required');
await b.evaluate('document.querySelector("#bus-view [data-retry]").click()');
await wait('document.querySelector("#bus-view h1").textContent==="Bus refused the read"');
assert.match(await text('#bus-view'), /started it from their own shell.*\(human-required\).*Nothing was written\./s);
s.fail(null);
await b.evaluate('document.querySelector("#bus-view [data-retry]").click()');
await wait('document.querySelector("#bus-view h1").textContent.startsWith("Trail for task #9")');
await noOverflow('refused');
// Back to the board: it still works.
await b.evaluate('document.querySelector("#sections a[href=\\"#/\\"]").click()');
await wait('document.querySelector("#bus-view").hidden && getComputedStyle(document.querySelector("#board-view")).display!=="none"');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 3);
assert.equal(await b.evaluate('document.title'), 'Mosaic Console');
// The skip link focuses main and does not route.
await b.evaluate('location.hash="#/agents"'); await wait('document.querySelector("#bus-view h1")?.textContent==="Agents"');
await b.evaluate('document.querySelector(".skip").click()');
assert.equal(await b.evaluate('location.hash'), '#/agents');
assert.equal(await b.evaluate('document.activeElement.id'), 'main');
assert.deepEqual(await b.evaluate('window.errors'), []);
const requests = await b.evaluate('window.requests');
assert.deepEqual(requests.filter(([m]) => m !== 'GET'), []);
assert.ok(requests.some(([, u]) => u.startsWith('/api/bus/trail?subject=')));
assert.deepEqual([...new Set(s.calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
// A Console with no bus configured says so, and its board still works.
await b.navigate(`${f.base}/#/inbox`); await wait('document.querySelector("#bus-view h1")?.textContent==="No bus to read"');
assert.match(await text('#bus-view'), /no bus configured.*The control board still works\. \(not-configured\)/s);
await b.evaluate('location.hash="#/"'); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { await b.close(); await close(web); await f.close(); s.store.close(); rmSync(root, { recursive: true, force: true }); }
});