Files
stack/packages/webui/tests/shell.test.mjs
T
jason.woltjeandClaude Opus 5.5 1bdb6f9b13 feat(webui): console tokens and shell restyle (#1542, row 53)
The console takes the design package's tokens.css and icons.svg as byte
copies, guarded by a test, and a new shell.css holds the components:
command bar with freshness line, section list (a strip that scrolls
inside itself below 760px), dense table, inspector, status mark, chips,
copy command, toast, Ctrl+K palette, not found, banners and skeleton
rows. JetBrains Mono 2.304 ships from the official archive with sha256
and OFL. Harbor following the system setting is the default palette.
A refusal (403, not-configured, no-bus-host, or the board's 403) fails
closed everywhere: no kept rows, counts, inspector, palette entries or
conversation history, and a later 500 or 503 doesn't bring them back.

Dewey built it in three rounds. Round 1 (75569953) and round 2
(78c3aef9) got changes from Darkwing (27148, 27165) and Filbert (27143,
27163) for refusal leaks outside the main view; round 3 (2562c05d) was
approved by Darkwing (27171) and Filbert (27174). Sage's gate on
ea080fd8 plus the candidate: runs 41/0, queue 148/0, webui 27/0,
conversation 182/0, control-board 124/0, build-tokens --check current,
every scripts/test-*.sh 0 failed (task 98/0 and release 14/0 with
Docker). The round 2 gate had one discord engine timing failure in the
full run, 66/0 alone, filed as #1553.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 14:49:48 -05:00

468 lines
40 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.
// #1542 (row 53): the Console shell restyle. Static checks: the design copies match
// docs/design byte for byte, the mono fonts match their recorded digests, and the shipped
// strings are unchanged. Browser checks: System is the default appearance, the board and
// bus states (empty, a failed read over kept data, a refusal), no sideways scroll at 400px,
// visible keyboard focus, the Ctrl+K palette, and each view in its five states. Nothing here
// writes; the bus is a stub.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { createServer } from 'node:http';
import { mkdirSync, readFileSync, readdirSync } 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 { BusReadError } from '../src/bus.mjs';
const pub = new URL('../src/public/', import.meta.url), design = new URL('../../../docs/design/', import.meta.url);
const read = (name, base = pub) => readFileSync(new URL(name, base));
test('tokens.css and icons.svg are byte copies of docs/design', () => {
for (const name of ['tokens.css', 'icons.svg']) assert.ok(read(name).equals(read(name, design)), `${name} differs from docs/design/${name}; copy it again`);
});
test('JetBrains Mono files match the digests in their sources file, with the OFL text', () => {
const fonts = new URL('assets/fonts/', pub);
const listed = new Map(read('jetbrains-mono-sources.txt', fonts).toString().split('\n').map(l => l.match(/^([0-9a-f]{64}) {2}(\S+)$/)).filter(Boolean).map(([, sum, file]) => [file, sum]));
const shipped = readdirSync(fonts).filter(f => f.startsWith('jetbrains-mono-') && f !== 'jetbrains-mono-sources.txt').sort();
assert.deepEqual([...listed.keys()].sort(), shipped);
assert.deepEqual(shipped, ['jetbrains-mono-400.woff2', 'jetbrains-mono-500.woff2', 'jetbrains-mono-700.woff2', 'jetbrains-mono-OFL.txt']);
for (const [file, sum] of listed) assert.equal(createHash('sha256').update(read(file, fonts)).digest('hex'), sum, file);
assert.match(read('jetbrains-mono-OFL.txt', fonts).toString(), /SIL OPEN FONT LICENSE Version 1\.1/);
const css = read('shell.css').toString();
for (const w of [400, 500, 700]) assert.ok(css.includes(`url(/assets/fonts/jetbrains-mono-${w}.woff2)`), `shell.css @font-face ${w}`);
});
test('shipped strings are unchanged, and the web keeps its write boundary', () => {
// Markup in the source (<code>, <b>) is not part of the string. Each string is counted in the
// file that ships it, so dropping one of two copies fails too.
const src = ['app.js', 'bus.js', 'index.html'].map(f => read(f).toString()).join('\n');
const plain = Object.fromEntries(['app.js', 'bus.js'].map(f => [f, read(f).toString().replace(/<\/?(code|b)>/g, '')]));
for (const [file, s, n = 1] of [
['bus.js', 'Copied: ${text}'],
['bus.js', 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.'],
['bus.js', 'Decisions are answered in a terminal with mosaic decide; Console only shows them.'],
['bus.js', 'No task ${txt(ref)} in this business.', 2], ['bus.js', 'No decision with id ${txt(id)}.'],
['bus.js', '${txt(b)} is not a decision id.'], ['bus.js', '${txt(b)} is not a task reference.'],
['bus.js', 'No page at this address.'], ['app.js', 'Nothing is waiting on you.'], ['bus.js', 'Nothing is waiting on you.'],
['bus.js', 'No tasks in this business yet.'], ['bus.js', 'Tasks appear after the PM creates them or the poller reads them from Vikunja.'],
['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'],
['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'],
]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`);
// No Resolve control and no bus write: the only POSTs are the board's Seen and reply.
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
assert.doesNotMatch(read('bus.js').toString(), /method:\s*['"](POST|PUT|PATCH|DELETE)/);
assert.equal(read('icons.svg').toString().includes('<script'), false);
});
function board() {
let reply = { status: 200, body: { 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 };
// While the board answers, agent1 has one conversation with one message. A failure or refusal answers every path.
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 };
let gate = null;
const server = createServer(async (req, res) => {
for await (const chunk of req) { void chunk; }
await gate;
const path = new URL(req.url, 'http://localhost').pathname;
const body = reply.status !== 200 ? reply.body : path === '/api/conversations' ? catalogue : path === '/api/conversation' ? page : reply.body;
res.writeHead(reply.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body));
});
return {
server,
// Holds every reply until the returned function is called.
hold: () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; },
empty: () => { reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } }; },
one: () => { reply = { status: 200, body: { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() } }; },
fail: () => { reply = { status: 500, body: { error: 'board scan failed' } }; },
refuse: () => { reply = { status: 403, body: { error: 'board-refused' } }; },
// One seat a reply can reach: registered, alive, with a tmux session.
registered: () => { reply = { status: 200, body: { sessions: [{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], counts: { waiting: 1 }, generatedAt: new Date().toISOString() } }; },
unavailable: () => { reply = { status: 503, body: { error: 'board unavailable' } }; },
};
}
// The four Q1 verbs over fixed rows, or a BusReadError with a chosen code.
function stubBus() {
const rows = { inbox: [], tasks: [], agents: [], trail: [] };
let fail = null, gate = null;
const verb = name => async () => { await gate; if (fail) throw new BusReadError(fail, `fixture ${fail}`); return structuredClone(rows[name]); };
const hold = () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; };
return { rows, hold, fail: code => { fail = code; }, bus: { inbox: verb('inbox'), tasks: verb('tasks'), agents: verb('agents'), trail: verb('trail') } };
}
test('shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette', { timeout: 180000 }, async () => {
const brd = board(); await new Promise(r => brd.server.listen(0, '127.0.0.1', r));
const s = stubBus();
const web = await startServer({ port: 0, board: `http://127.0.0.1:${brd.server.address().port}`, bus: s.bus });
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'); };
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]")`); };
try {
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(400, 900);
// System is the default: no data-mode, Harbor, the OS picks light or dark. Colours come from tokens.css, not inline.
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'dark' }] });
await b.navigate(base); await wait('!document.querySelector("#sessions [aria-busy]")');
assert.equal(await b.evaluate('document.documentElement.hasAttribute("data-mode")'), false);
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
assert.equal(await b.evaluate('document.querySelector("#mode").value'), 'system');
assert.equal(await b.evaluate('document.documentElement.getAttribute("style")'), null);
const lum = await b.evaluate('(()=>{const [r,g,bb]=getComputedStyle(document.body).backgroundColor.match(/[\\d.]+/g).map(Number);return (r+g+bb)/3})()');
assert.ok(lum < 80, `dark OS gives a dark page under System (${lum})`);
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] });
assert.ok(await b.evaluate('(()=>{const [r,g,bb]=getComputedStyle(document.body).backgroundColor.match(/[\\d.]+/g).map(Number);return (r+g+bb)/3})()') > 180, 'light OS gives a light page');
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r").trim()'), '6px');
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r-lg").trim()'), '10px');
assert.equal(await b.evaluate('document.fonts.check("12px \\"JetBrains Mono\\"")'), true);
await b.evaluate('document.querySelector("#mode").value="dark";document.querySelector("#mode").dispatchEvent(new Event("change"))');
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'dark');
assert.equal(await b.evaluate('JSON.parse(localStorage.getItem("mosaic-console-appearance")).mode'), 'dark');
await b.evaluate('document.querySelector("#mode").value="system";document.querySelector("#mode").dispatchEvent(new Event("change"))');
assert.equal(await b.evaluate('document.documentElement.hasAttribute("data-mode")'), false);
// Board, empty: what would appear and where it comes from.
assert.match(await text('#sessions .empty'), /No sessions match these filters\.Sessions appear here when the board scan finds/);
assert.match(await text('#waiting .empty'), /Nothing is waiting on you\./);
assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/);
await flat(); await shot('board-empty-400');
// Board, a failed read over kept data: the data stays, labelled with its scan time, and the bar says stale.
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions .st-waiting").length'), 1);
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("table.sessions .st-waiting"),"::before").content'), '"◐" / ""');
brd.fail(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
assert.match(await text('#error'), /board scan failed.*Showing last known data\. Scanned /);
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 2);
assert.match(await text('#fresh-board'), /stale: the last refresh failed/);
await flat(); await shot('board-stale-400');
// 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.
brd.unavailable(); await refresh();
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);
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);
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
// Keyboard on the board: down moves between rows, Enter opens, Esc closes and returns focus.
await b.evaluate('document.querySelector("table.sessions [data-open]").focus()');
await b.key('ArrowDown'); assert.equal(await b.evaluate('document.activeElement.textContent'), 'agent2');
await b.key('ArrowUp'); assert.equal(await b.evaluate('document.activeElement.textContent'), 'agent1');
await b.key('Enter'); await wait('document.querySelector("#inspector-title")?.textContent==="agent1"');
await flat();
await b.evaluate('document.querySelector("#close").focus()'); await b.key('Escape');
await wait('document.activeElement?.matches("[data-open]") && document.activeElement.textContent==="agent1"');
// Visible focus: a keyboard-focused control has an outline of at least 2px.
await b.evaluate('document.querySelector(".skip").focus();document.querySelector(".skip").blur()');
for (const sel of ['#cmdk', '#sections a', '#refresh']) {
await b.evaluate(`document.querySelector(${JSON.stringify(sel)}).focus()`);
const ring = await b.evaluate(`(()=>{const c=getComputedStyle(document.activeElement);return [c.outlineStyle, parseFloat(c.outlineWidth), parseFloat(c.outlineOffset)]})()`);
assert.notEqual(ring[0], 'none', `${sel} focus ring`); assert.ok(ring[1] >= 2, `${sel} outline ${ring[1]}px`); assert.equal(ring[2], 2, `${sel} outline offset`);
}
// Bus pages, empty: each says what would appear and where it comes from.
for (const [hash, h1, words] of [
['#/inbox', 'Inbox', /^Nothing is waiting on you\.A decision shows here when an agent raises one routed to you through the bus\.$/],
['#/tasks', 'Tasks', /^No tasks in this business yet\.Tasks appear after the PM creates them or the poller reads them from Vikunja\.$/],
['#/agents', 'Agents', /^No role is held right now\.A role shows here while a run holds its claim on the bus\.$/],
]) {
await go(hash, h1);
assert.match(await text('#bus-view .empty'), words, hash);
assert.match(await text('#fresh-bus'), /^Bus read \d\d:\d\d:\d\d UTC/, hash);
await flat(); await shot(`bus-empty-${h1.toLowerCase()}-400`);
}
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#fresh-board")).display'), 'none');
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 4);
for (const sel of ['#fresh', '#session-count']) assert.equal(await b.evaluate(`getComputedStyle(document.querySelector(${JSON.stringify(sel)})).fontVariantNumeric`), 'tabular-nums', sel);
// Before any copy the toast is an empty live region still in the accessibility tree, out of sight.
assert.deepEqual(await b.evaluate('(()=>{const t=document.querySelector("#toast"),c=getComputedStyle(t),r=t.getBoundingClientRect();return [c.display!=="none",c.visibility,t.textContent,r.width<=1&&r.height<=1]})()'), [true, 'visible', '', true]);
// Below 760px the section list is one strip that scrolls inside itself: every link on one
// line, no label wrapped, and still no sideways scroll on the page. 360px, then back to 400px.
for (const w of [360, 400]) {
await b.viewport(w, 900);
assert.equal(await b.evaluate('new Set([...document.querySelectorAll("#sections a")].map(a=>a.offsetTop)).size'), 1, `one row at ${w}px`);
assert.match(await b.evaluate('getComputedStyle(document.querySelector("#sections")).overflowX'), /^(auto|scroll)$/, `strip scrolls at ${w}px`);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections a")].filter(a=>a.getClientRects().length>1||a.scrollWidth>a.clientWidth+1).map(a=>a.textContent)'), [], `no wrapped label at ${w}px`);
await flat();
}
// Bus, a failed read over kept data: the kept rows stay under a stale banner; the bar says stale.
s.rows.tasks = [{ task_ref: 'vikunja:32/7', fields: { title: 'Kept task', bucket: 2, done: false, assignees: [] }, source: 'self' }, { task_ref: 'vikunja:32/8', fields: { title: 'Second task', bucket: 2, done: false, assignees: [] }, source: 'self' }];
await go('#/trail/task/vikunja%3A32%2F7', 'Trail for task #7');
assert.match(await text('#bus-view .empty'), /^Nothing of this kind in the trail\.Every step the stack takes on this subject writes a trail row\.$/);
await flat(); await shot('bus-empty-trail-400');
await go('#/tasks', 'Tasks');
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 2);
s.fail('outcome-unknown'); await b.evaluate('document.querySelector("#refresh").click()');
await wait('!!document.querySelector("#bus-view .s1-stale")');
assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale").className'), 'banner err s1-stale');
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 2);
assert.match(await text('#fresh-bus'), /stale: the last read failed/);
// Up and down move between task links.
await b.evaluate('document.querySelector("#bus-view td.s1-title a").focus()');
await b.key('ArrowDown'); assert.match(await b.evaluate('document.activeElement.textContent'), /Second task/);
await b.key('ArrowUp'); assert.match(await b.evaluate('document.activeElement.textContent'), /Kept task/);
await flat(); await shot('bus-stale-400');
// Bus, a refusal: fails closed even with kept data, the code in mono, nothing shown as current.
s.fail('human-required'); await b.evaluate('document.querySelector("#refresh").click()');
await wait('!!document.querySelector("#bus-view .banner.ref")');
assert.equal(await b.evaluate('document.querySelector("#bus-view .banner.ref code").textContent'), 'human-required');
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 0);
assert.match(await text('#bus-view .empty'), /Nothing to show\./);
assert.equal(await text('#fresh-bus'), 'Bus not read: refused');
await flat(); await shot('bus-refused-400');
s.fail(null);
// Not found names the address and points to the Board and to Ctrl K.
await go('#/nowhere', 'Not found');
assert.match(await text('#bus-view .empty'), /No page at this address\.Nothing is at #\/nowhere\. Go to the Board or press Ctrl K to search\./);
assert.equal(await b.evaluate('document.querySelector("#bus-view .empty a").getAttribute("href")'), '#/');
// Ctrl+K: opens on the views and the kept tasks, filters, Enter goes, Esc restores focus.
await go('#/inbox', 'Inbox');
await b.evaluate('document.querySelector("#refresh").focus()');
await b.key('k', 'KeyK', 3); await new Promise(r => setTimeout(r, 200));
assert.equal(await b.evaluate('document.querySelector("#cmdk-dialog").open'), false, 'Ctrl+Alt+K opens nothing');
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
assert.equal(await b.evaluate('document.activeElement.id'), 'cmdk-input');
await wait('[...document.querySelectorAll("#cmdk-list [data-href]")].some(e=>e.dataset.href==="#/tasks/vikunja%3A32%2F7")');
await b.call('Input.insertText', { text: 'agen' });
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)'), ['#/agents']);
// The view render focuses its h1; wait for it so a late render can't take focus from the next step.
await b.key('Enter'); await wait('location.hash==="#/agents" && !document.querySelector("#cmdk-dialog").open && document.activeElement===document.querySelector("#bus-view h1") && document.activeElement.textContent==="Agents"');
await b.evaluate('document.querySelector("#refresh").focus()');
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
await b.call('Input.insertText', { text: 'kept' });
assert.equal(await b.evaluate('document.querySelector("#cmdk-list [aria-selected=true]").dataset.href'), '#/tasks/vikunja%3A32%2F7');
await b.key('ArrowDown'); await b.key('ArrowUp');
assert.equal(await b.evaluate('document.querySelector("#cmdk-input").getAttribute("aria-activedescendant")'), 'cmdk-opt-0');
await flat(); await shot('palette-400');
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open && document.activeElement?.id==="refresh"');
await b.evaluate('{const c=document.querySelector("#cmdk");c.focus();c.click()}'); await wait('document.querySelector("#cmdk-dialog").open');
await b.call('Input.insertText', { text: 'zzzz' });
assert.match(await text('#cmdk-list'), /No view, decision or task matches\./);
await b.key('Escape'); await wait('document.activeElement?.id==="cmdk"');
// Copy shows a toast and keeps the status line; the toast never steals focus.
s.rows.inbox = [{ id: 'd'.repeat(32), question: 'Push it?', class: 'gated', action: 'git.push.protected', target: 'refactor', blocking: true, options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', at: new Date().toISOString(), raised_by: 'coder' }];
// The decision page reads the decision from its trail, in the broker's row shape.
s.rows.trail = [{ table: 'decisions', seq: 1, at: s.rows.inbox[0].at, id: 'd'.repeat(32), class: 'gated', action: 'git.push.protected', route_to: 'human', question: 'Push it?', options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', blocking: 1, raised_by_role: 'coder', raised_by_run: 'coder-run' }];
await go('#/inbox', 'Inbox');
assert.equal(await b.evaluate('document.querySelector("#bus-view .tag.tag-gated code").textContent'), 'git.push.protected');
assert.equal(await b.evaluate('document.querySelector("#bus-view .tag.tag-block").textContent'), 'blocking');
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#bus-view .tag.tag-block")).textTransform'), 'uppercase');
await flat(); await shot('bus-inbox-400');
await b.evaluate('document.querySelector("#bus-view .s1-dec-open").click()'); await wait('document.querySelectorAll("#bus-view [data-copy]").length===1');
await b.evaluate('window.copied=[];navigator.clipboard.writeText=async t=>{copied.push(t)}');
await b.evaluate('{const c=document.querySelector("#bus-view [data-copy]");c.focus();c.click()}');
await wait('document.querySelector("#toast").classList.contains("on")');
assert.equal(await text('#toast'), `Copied: mosaic decide ${'d'.repeat(32)} yes`);
assert.equal(await text('#copy-status'), `Copied: mosaic decide ${'d'.repeat(32)} yes`);
assert.equal(await b.evaluate('document.activeElement.matches("[data-copy]")'), true);
await flat(); await shot('toast-400');
await b.evaluate('navigator.clipboard.writeText=async()=>{throw new Error("denied")}');
await b.evaluate('document.querySelector("#bus-view [data-copy]").click()');
await wait('document.querySelector("#toast").classList.contains("err")');
assert.equal(await text('#toast span'), 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.');
assert.equal(await b.evaluate('getSelection().toString()'), `mosaic decide ${'d'.repeat(32)} yes`);
await new Promise(r => setTimeout(r, 3000));
assert.equal(await b.evaluate('document.querySelector("#toast").className'), 'toast on err', 'the error toast stays until dismissed');
await b.evaluate('document.querySelector("#toast [data-dismiss]").click()');
assert.equal(await b.evaluate('document.querySelector("#toast").classList.contains("on")'), false);
assert.deepEqual(await b.evaluate('window.errors'), []);
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
await b.viewport(1440, 900); await go('#/tasks', 'Tasks'); await shot('bus-tasks-1440');
await b.evaluate('location.hash="#/"'); await wait('document.querySelectorAll("table.sessions tbody tr").length===2'); await shot('board-1440');
// The side column sits between the command bar and the status footer: a short page has no scroll,
// and scrolled to the end of a long one the column is still below the bar.
const edges = 'JSON.stringify(["cmdbar","tree","foot"].map(c=>document.querySelector("."+c).getBoundingClientRect()).map(r=>[Math.round(r.top),Math.round(r.bottom)]).concat([[document.documentElement.scrollHeight,innerHeight]]))';
let [bar, tree, foot, [scrollH, innerH]] = JSON.parse(await b.evaluate(edges));
assert.equal(scrollH, innerH);
assert.deepEqual([tree[0], tree[1]], [bar[1], foot[0]]);
await b.viewport(1440, 420); await b.evaluate('scrollTo(0, 1e5)'); await flat();
[bar, tree, foot, [scrollH, innerH]] = JSON.parse(await b.evaluate(edges));
assert.ok(scrollH > innerH, `the board page is taller than ${innerH}px`);
assert.deepEqual([tree[0], tree[1]], [bar[1], foot[0]]);
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { await b.close(); await close(web); await close(brd.server); }
});
// Every restyled view in its five states: loading (a held read), normal, empty, a failed read
// over kept data, and a refusal. Empty is seeded per view above; here each view is read once
// with rows, then made to fail, then refused, at 400px.
test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }, async () => {
const brd = board(); await new Promise(r => brd.server.listen(0, '127.0.0.1', r));
const s = stubBus();
const at = new Date().toISOString(), id = 'd'.repeat(32);
s.rows.inbox = [{ id, question: 'Push it?', class: 'gated', action: 'git.push.protected', target: 'refactor', blocking: true, options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', at, raised_by: 'coder' }];
s.rows.trail = [{ table: 'decisions', seq: 1, at, id, class: 'gated', action: 'git.push.protected', route_to: 'human', question: 'Push it?', options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', blocking: 1, raised_by_role: 'coder', raised_by_run: 'coder-run' }];
s.rows.tasks = [{ task_ref: 'vikunja:32/7', fields: { title: 'Kept task', bucket: 2, done: false, assignees: [] }, source: 'self' }, { task_ref: 'vikunja:32/8', fields: { title: 'Second task', bucket: 2, done: false, assignees: [] }, source: 'self' }];
s.rows.agents = [{ role: 'coder', holder_run: 'coder-run', harness: 'pi', address: 'coder@bus', at }];
const web = await startServer({ port: 0, board: `http://127.0.0.1:${brd.server.address().port}`, bus: s.bus });
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, `states-${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'); };
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]")`); };
try {
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(400, 900);
// Board, loading: the first scan is held, so the sessions list shows its skeleton.
brd.one(); let release = brd.hold();
await b.navigate(base); await wait('!!document.querySelector("#sessions .skel-rows[aria-busy=true]")');
await flat(); await shot('board-loading-400');
release(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
await flat(); await shot('board-normal-400');
for (const [hash, h1, rows, name] of [
['#/inbox', 'Inbox', '#bus-view .s1-dec-open', 'inbox'],
['#/tasks', 'Tasks', '#bus-view td.s1-title a', 'tasks'],
['#/agents', 'Agents', '#bus-view table.s1-agents tbody tr', 'agents'],
[`#/trail/decision/${id}`, 'Trail for decision', '#bus-view ol.s1-trail li', 'trail'],
]) {
// Loading: the read is held, the view shows the skeleton under "Reading…".
release = s.hold();
await b.evaluate(`location.hash=${JSON.stringify(hash)}`);
await wait('!!document.querySelector("#bus-view .skel-rows[aria-busy=true]")');
assert.equal(await text('#bus-view h1'), 'Reading…', hash);
await flat(); await shot(`${name}-loading-400`);
// Normal.
release();
await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`);
const n = await count(rows);
assert.ok(n >= 1, `${hash} shows its rows`);
assert.equal(await count('#bus-view .banner'), 0, hash);
assert.match(await text('#fresh-bus'), /^Bus read \d\d:\d\d:\d\d UTC/, hash);
await flat(); await shot(`${name}-normal-400`);
// A failed read over kept data: the same rows under a stale banner.
s.fail('outcome-unknown'); await refresh();
await wait('!!document.querySelector("#bus-view .s1-stale")');
assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale").className'), 'banner err s1-stale', hash);
assert.equal(await count(rows), n, `${hash} keeps its rows`);
assert.match(await text('#fresh-bus'), /stale: the last read failed/, hash);
await flat(); await shot(`${name}-stale-400`);
// A refusal, by the bus (403) or for want of one (no-bus-host): no kept rows, the code
// shown, nothing current, and no count in the section list.
for (const code of ['human-required', 'no-bus-host']) {
s.fail(code); await refresh();
await wait('!!document.querySelector("#bus-view .banner.ref")');
assert.equal(await text('#bus-view .banner.ref code'), code, hash);
assert.equal(await count(rows), 0, `${hash} hides kept rows on a ${code} refusal`);
assert.match(await text('#bus-view .empty'), /Nothing to show\./, hash);
assert.equal(await text('#fresh-bus'), 'Bus not read: refused', hash);
assert.equal(await count('#sections .count, #sections .tag-block'), 0, `${hash} drops the Inbox count on a ${code} refusal`);
if (code === 'human-required') { await flat(); await shot(`${name}-refused-400`); }
s.fail(null); await refresh();
await wait(`!document.querySelector("#bus-view .banner") && document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`);
}
}
// After a refusal the palette lists only the views: no kept decision or task.
const views = ['#/', '#/inbox', '#/tasks', '#/agents'];
const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
assert.match(await text('#sections'), /Inbox 1 1 blocking/);
s.fail('human-required'); await refresh(); await wait('!!document.querySelector("#bus-view .banner.ref")');
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
assert.deepEqual(await b.evaluate(palette), views);
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
s.fail(null);
// The palette's own reads: kept rows show until a refused refetch, then go, with the count.
await go('#/tasks', 'Tasks'); await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
await b.key('k', 'KeyK', 2); await wait(`${palette}.some(h=>h.startsWith("#/tasks/"))`);
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
s.fail('human-required');
await b.key('k', 'KeyK', 2); await wait(`document.querySelector("#cmdk-dialog").open && ${palette}.length===${views.length} && !document.querySelector("#sections .count")`);
assert.deepEqual(await b.evaluate(palette), views);
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
// Palette labels are bus text: markup in a question or a task title shows as text.
s.fail(null);
s.rows.inbox = [{ ...s.rows.inbox[0], question: '<img src=x onerror="errors.push(1)" id="pk-q">Push?' }];
s.rows.tasks = [{ ...s.rows.tasks[0], fields: { ...s.rows.tasks[0].fields, title: '<b id="pk-t">Bold</b> task' } }, s.rows.tasks[1]];
await b.key('k', 'KeyK', 2); await wait(`${palette}.length===${views.length + 3}`);
assert.equal(await count('#cmdk-list #pk-q, #cmdk-list #pk-t, #cmdk-list img, #cmdk-list b'), 0);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#cmdk-list [data-href] span:first-child")].slice(4).map(e=>e.textContent)'), ['<img src=x onerror="errors.push(1)" id="pk-q">Push?', '#7 <b id="pk-t">Bold</b> task', '#8 Second task']);
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
// The board page's Inbox read: a refusal there drops the count too.
s.fail(null); await refresh(); await wait('document.querySelectorAll("#sections .count").length===1');
s.fail('no-bus-host'); await b.evaluate('location.hash="#/"'); await wait('!document.querySelector("#sections .count")');
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
assert.deepEqual(await b.evaluate(palette), views);
await b.key('Escape'); s.fail(null);
assert.deepEqual(await b.evaluate('window.errors'), []);
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
} finally { await b.close(); await close(web); await close(brd.server); }
});