// #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 (, ) 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, /]*>[^<]*(Resolve|Decide|Approve)/i); assert.doesNotMatch(read('bus.js').toString(), /method:\s*['"](POST|PUT|PATCH|DELETE)/); assert.equal(read('icons.svg').toString().includes(' { 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: 'Push?' }]; s.rows.tasks = [{ ...s.rows.tasks[0], fields: { ...s.rows.tasks[0].fields, title: 'Bold 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)'), ['Push?', '#7 Bold 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); } });