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]>
This commit is contained in:
2026-10-10 14:49:48 -05:00
co-authored by Claude Opus 5.5
parent 1bb8ab54fe
commit 1bdb6f9b13
17 changed files with 1833 additions and 44 deletions
+2 -2
View File
@@ -65,8 +65,8 @@ export async function browser() {
await writeFile(path, Buffer.from(data, 'base64'));
},
async key(key, code = key, modifiers = 0) {
const virtual = { Tab: 9, Enter: 13, Escape: 27, ArrowDown: 40, ArrowRight: 39, ' ': 32 }[key];
await call('Input.dispatchKeyEvent', { type: 'keyDown', key, code, modifiers, windowsVirtualKeyCode: virtual, text: key === 'Enter' ? '\r' : key.length === 1 ? key : '' });
const virtual = { Tab: 9, Enter: 13, Escape: 27, ArrowDown: 40, ArrowUp: 38, ArrowRight: 39, ' ': 32, k: 75 }[key];
await call('Input.dispatchKeyEvent', { type: 'keyDown', key, code, modifiers, windowsVirtualKeyCode: virtual, text: modifiers ? '' : key === 'Enter' ? '\r' : key.length === 1 ? key : '' });
await call('Input.dispatchKeyEvent', { type: 'keyUp', key, code, modifiers, windowsVirtualKeyCode: virtual });
},
async close() {
+4
View File
@@ -36,6 +36,10 @@ test('real board fixture passes through WebUI; assets and isolated seen/reply wo
for (const path of ['/', '/app.js', '/brand.js', '/console.css', '/live.css', '/shared/app.css', '/assets/fonts/manrope-400.woff2']) {
const response = await fetch(f.base + path); assert.equal(response.status, 200, path); assert.equal(response.headers.get('cache-control'), 'no-store');
}
// #1542: the shell's tokens, sprite, stylesheet and mono fonts, each with its type.
for (const [path, type] of [['/tokens.css', 'text/css'], ['/shell.css', 'text/css'], ['/icons.svg', 'image/svg+xml'], ...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2'])]) {
const response = await fetch(f.base + path); assert.equal(response.status, 200, path); assert.ok(response.headers.get('content-type').startsWith(type), path);
}
assert.equal((await fetch(f.base + '/../../AGENTS.md')).status, 404);
assert.equal((await fetch(f.base + '/api/reply')).status, 405);
assert.equal((await fetch(f.base + '/api/board', { method: 'OPTIONS' })).status, 405);
+467
View File
@@ -0,0 +1,467 @@
// #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); }
});