Files
stack/packages/webui/tests/bus-browser.test.mjs
T
jason.woltjeandClaude Opus 5.5 a85120070b bus: agent state per role, and the Console shows it (#1548)
Row 59 round 2, owner Filbert, candidate manifest 9acfac19 on 3d298920.
Approved by Darkwing (27591) and Dewey (27595); Sage's gate green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 20:13:43 -05:00

307 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');
const cto = launch('cto', 'claude-code', 'cto-thread');
// Row 59 (#1548): CTO has a message it received and hasn't answered, so it is working.
b.request(pm, { verb: 'message.send', args: { to: 'cto', body: 'Review the inbox design' } });
b.request(cto, { verb: 'message.receive' });
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, priority: undefined } }] });
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 })); } });
// Row 59 (#1548): a second open decision for Coder, raised and withdrawn on the Agents page.
const second = { raise: () => raise({ ...push, question: 'Tag the release?', target: 'tag', blocking: false }).id, withdraw: id => b.request(coder, { verb: 'decision.withdraw', args: { id } }) };
return { store, bus, calls, second, 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/);
// Requirement and priority come from the bus row (row 58). The list reads no trail, so #7's
// requirement is the row's. #8 and #9 have no task.created, and #9 was written with no priority.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-tasks")].map(t=>[[...t.tHead.rows[0].cells].map(c=>c.textContent),...[...t.tBodies[0].rows].map(r=>[r.cells[1].textContent,r.cells[4].textContent])])'), [
['Task Requirement Assignees Due Priority Done Changed Markers'.split(' '), ['none', 'high']],
['Task Requirement Assignees Due Priority Done Changed Markers'.split(' '), ['REQ-SLICE-1', 'high']],
['Task Requirement Assignees Due Priority Done Changed Markers'.split(' '), ['none', 'none']],
]);
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, /Priorityhigh/);
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\)/);
// A tombstone's bus row has no priority; the detail shows the last snapshot that read the task.
assert.match(await text('#bus-view'), /Priorityhigh/);
// 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/);
// Row 59 (#1548): the state mark and Input needed per role. Coder's open push decision waits on the human.
const agentRow = role => b.evaluate(`(() => { const r = [...document.querySelectorAll("#bus-view table.s1-agents tbody tr")].find(r => r.querySelector("th").textContent === ${JSON.stringify(role)}); return [...r.querySelectorAll("td")].map(td => td.textContent); })()`);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents thead th")].map(e=>e.textContent)'), ['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since']);
const [coderRow, ctoRow, pmRow] = [await agentRow('coder'), await agentRow('cto'), await agentRow('pm')];
assert.equal(coderRow[0], 'waiting'); assert.match(coderRow[1], /^Input needed: Push the release\? .*blocking$/);
assert.ok(!coderRow[1].includes('Second line'), 'only the first line of the question');
assert.match(ctoRow[0], /^working1 message since /); assert.equal(ctoRow[1], 'none');
assert.deepEqual([pmRow[0], pmRow[1]], ['idle', 'none']);
assert.equal(await b.evaluate('document.querySelector("#bus-view table.s1-agents td a[href^=\\"#/inbox/\\"]")?.getAttribute("href")'), `#/inbox/${s.ids.open}`);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents .st")].map(e=>[e.className.split(" ").find(c=>c.startsWith("st-")), getComputedStyle(e,"::before").content])').then(r => r.sort()), [['st-idle', '"–" / ""'], ['st-waiting', '"◐" / ""'], ['st-working', '"●" / ""']]);
// Gated first, then oldest: the push decision stays first and the new one is counted.
const extra = s.second.raise();
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("#bus-view table.s1-agents")?.textContent.includes("+1 more")');
assert.match((await agentRow('coder'))[1], /^Input needed: Push the release\? .*blocking \+1 more$/);
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents td a")].find(a=>a.textContent==="+1 more").getAttribute("href")'), '#/inbox');
s.second.withdraw(extra);
await b.evaluate('document.querySelector("#refresh").click()');
await wait('!document.querySelector("#bus-view table.s1-agents")?.textContent.includes("more")');
await inertCheck('agents');
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');
// #9 was written with no priority and has no task.created. This runs after the never-read
// check above, which needs #9's trail unread.
await go('#/tasks/vikunja%3A32%2F9', '#9');
assert.match(await text('#bus-view'), /Requirementnone/);
assert.match(await text('#bus-view'), /Prioritynone/);
// 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 }); }
});