// 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 = '
\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\?