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