// Row 54 (#1543): the Queue, Business and Settings views in a browser. Each view in its five // states over a stub read: loading, normal, empty, a failed read over kept data, and a refusal // that shows nothing kept. Queue rows move only with `queue move`, so the page shows that command // and no button for it. Over the seeded fixture (reads-fixture.mjs), no secret, Discord id or // temporary path reaches a response, the page or the server's log, and a missing or invalid // business file shows the business module's own refusal text. Nothing here writes. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { browser } from './browser.mjs'; import { close } from './fixture.mjs'; import { startServer } from '../src/serve.mjs'; import { consoleReads, ReadError } from '../src/reads.mjs'; import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs'; import { SRC, NOTE_SHOWN, queueRepo, seeded, clean } from './reads-fixture.mjs'; const PROBE = 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];window.bodies=[];const f=window.fetch;window.fetch=async(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);const r=await f(u,o);bodies.push(await r.clone().text());return r};' // The bus view's 10s refresh is recorded so a test can fire it early, unless it was cleared. + '(()=>{const st=setTimeout,ct=clearTimeout;window.refreshes=new Map();window.setTimeout=(fn,ms,...a)=>{if(ms!==10000||!String(fn).includes("render(false)"))return st(fn,ms,...a);const id=st(()=>{refreshes.delete(id);fn()},ms);refreshes.set(id,fn);return id};window.clearTimeout=id=>{refreshes.delete(id);return ct(id)};window.fireRefresh=()=>{const due=[...refreshes];refreshes.clear();for(const[id,fn]of due){ct(id);fn()}return due.length}})();'; // The three reads over fixed documents, or a ReadError with a chosen code and message. function stubReads() { const row = { id: 6, piece: 'Console views', state: 'in-progress', owner: 'dewey', reviewers: ['darkwing', 'filbert'], brief: { path: 'docs/plans/brief.md', anchor: 'Row six' }, updatedAt: '2026-10-10T12:00:00Z' }; const docs = { queue: { rows: [row, { ...row, id: 7, piece: 'Second row', state: 'done' }], notes: [] }, business: { id: 'acme', human: 'jason', arbiters: { delivery: 'pm' }, projects: ['stack'], vars: { 'tracker.pollSeconds': 60 }, actions: ['role.launch'], instances: [{ instance: 'pm', definition: 'pm', holder: 'sage', vars: { harness: 'pi' }, authority: { 'role.launch': 'within' } }], launch: { by: 'pm', instances: ['pm'], max: { opus: 1 } }, credentials: [{ service: 'gitea', account: 'bot-acme', role: 'pm', dateKind: 'expires', date: '2099-01-01', state: 'valid' }], }, settings: { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' } }, }; let fail = null, gate = null; const answer = async make => { await gate; if (fail) throw new ReadError(fail.code, fail.message); return { ...structuredClone(make()), at: new Date().toISOString() }; }; const reads = { queue: () => answer(() => docs.queue), row: id => answer(() => { const r = docs.queue.rows.find(x => x.id === id); if (!r) throw new ReadError('not-found', `no row ${id}`); return { row: { ...r, review: { rounds: [] } }, notes: [] }; }), business: () => answer(() => ({ business: docs.business })), settings: () => answer(() => docs.settings), }; const hold = () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; }; return { docs, reads, hold, fail: (code, message = `fixture ${code}`) => { fail = code ? { code, message } : null; } }; } async function page(b, base) { const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<200;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 what => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, `sideways scroll at 400px: ${what}`); 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]")`); }; await b.call('Page.addScriptToEvaluateOnNewDocument', { source: PROBE }); await b.viewport(400, 900); await b.navigate(base); await wait('document.querySelectorAll("#sections a").length===7'); return { wait, text, count, flat, refresh, go }; } test('Queue, Business and Settings in their five states, with no move button', { timeout: 180000 }, async () => { const s = stubReads(); // A bus that answers, so the palette's own inbox and tasks reads succeed and forget nothing. const bus = { inbox: async () => [], tasks: async () => [], agents: async () => [], trail: async () => [] }; const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: s.reads, bus }); const b = await browser(); try { const { wait, text, count, flat, refresh, go } = await page(b, `http://127.0.0.1:${web.address().port}/`); const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)'; // Esc closes the palette at once, but its close event, which puts focus back, is a later task. // Waiting for that event keeps a late focus from landing on the next step (seen under load). const closePalette = async () => { await b.evaluate('window.paletteClosed=new Promise(r=>document.querySelector("#cmdk-dialog").addEventListener("close",()=>setTimeout(r),{once:true}));true'); await b.key('Escape'); await b.evaluate('window.paletteClosed.then(()=>true)'); }; for (const [hash, h1, source, rows, refusals, emptied, emptyText] of [ ['#/queue', 'Queue', 'Queue', '#bus-view table.s1-queue tbody tr', ['queue-refused', 'not-configured'], () => { s.docs.queue.rows = []; }, 'The queue has no rows.'], ['#/queue/6', 'Row 6', 'Queue', '#bus-view [aria-labelledby=q-row] dl', ['queue-refused'], null, null], ['#/business', 'Business acme', 'Business', '#bus-view table.s1-roles tbody tr', ['not-configured', 'no-bus-host'], () => { s.docs.business = { ...s.docs.business, instances: [], credentials: [] }; }, 'This business has no role instances.'], ['#/settings', 'Settings', 'Settings', '#bus-view [aria-labelledby=st-notify] dl', ['not-configured'], () => { s.docs.settings = { ...s.docs.settings, notifier: { refused: 'not-configured', message: 'no notifier config at /notify/acme/notify.json' } }; }, 'No notifier binding to show.'], ]) { const keep = structuredClone(s.docs); // Loading: the read is held; the skeleton names the source. const 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); assert.equal(await text('#bus-view .skel-rows .sr-only'), `Reading the ${source.toLowerCase()}.`, hash); await flat(`${hash} loading`); // 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'), new RegExp(`^${source} read \\d\\d:\\d\\d:\\d\\d UTC`), hash); await flat(`${hash} normal`); // A refresh of the same page replaces the heading and keeps focus on it (the 10s refresh does the same). await b.evaluate('document.querySelector("#bus-view h1").focus(); document.querySelector("#bus-view h1").dataset.old = "1"'); await refresh(); await wait('!document.querySelector("#bus-view h1").dataset.old'); assert.equal(await b.evaluate('document.activeElement === document.querySelector("#bus-view h1")'), true, `${hash} keeps heading focus`); // A failed read over kept data: the same rows under a stale banner, with the read time. s.fail('read-failed', 'the queue read failed'); await refresh(); await wait('!!document.querySelector("#bus-view .s1-stale")'); assert.equal(await count(rows), n, `${hash} keeps its rows`); assert.match(await text('#bus-view .s1-stale'), /The last read failed\. The read did not finish\. \(read-failed\)/, hash); assert.match(await text('#fresh-bus'), new RegExp(`^${source} read .* stale: the last read failed`), hash); await flat(`${hash} stale`); // A refusal: no kept rows anywhere, the code in mono and the module's own text. for (const code of refusals) { const said = `fixture ${code}: /businesses/acme.json`; s.fail(code, said); await refresh(); await wait('!!document.querySelector("#bus-view .banner.ref")'); assert.equal(await text('#bus-view .banner.ref b code'), code, hash); assert.equal(await text('#bus-view .banner.ref .source'), said, hash); assert.equal(await count(rows), 0, `${hash} hides kept rows on ${code}`); assert.match(await text('#bus-view .empty'), /Nothing to show\./, hash); assert.equal(await text('#fresh-bus'), `${source} not read: refused`, hash); // Settings keeps its appearance control through a refusal; it is this browser's own. assert.equal(await count('#bus-view select[data-mirror]'), hash === '#/settings' ? 2 : 0, hash); await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open'); assert.equal(await b.evaluate(`${palette}.filter(h=>h.startsWith("#/queue/")).length`), 0, `${hash}: no kept queue row in the palette after ${code}`); await closePalette(); await flat(`${hash} refused`); s.fail(null); await refresh(); await wait(`!document.querySelector("#bus-view .banner") && document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`); } // Empty. if (emptied) { emptied(); await refresh(); await wait(`document.querySelector("#bus-view .empty b")?.textContent===${JSON.stringify(emptyText)}`); await flat(`${hash} empty`); Object.assign(s.docs, keep); await refresh(); await wait(`document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`); } } // A row that is not in the queue, and an address that is not a row id. await go('#/queue/9', 'Not found'); assert.equal(await text('#bus-view .nf b'), 'No row 9 in the queue.'); await go('#/queue/x9', 'Not found'); assert.equal(await text('#bus-view .nf b'), 'x9 is not a queue row id.'); // Queue: state, owner, reviewers and the brief, text only; the move command, never a button. await go('#/queue', 'Queue'); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-queue tbody tr:first-child td")].slice(0,5).map(e=>e.textContent)'), ['6 Console views', 'in-progress', 'dewey', 'darkwing, filbert', 'docs/plans/brief.md#Row six']); assert.equal(await count('#bus-view #q-b'), 0); // Fix to HEAD behaviour: a navigation clears the pending refresh. Fired during the // navigation's read, the refresh would replace it and focus would fall to . assert.equal(await b.evaluate('refreshes.size'), 1, 'a refresh is due on #/queue'); const release = s.hold(); await b.evaluate('location.hash="#/business"'); await wait('!!document.querySelector("#bus-view .skel-rows[aria-busy=true]")'); assert.equal(await b.evaluate('fireRefresh()'), 0, 'the navigation cleared the due refresh'); release(); await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Business") && !document.querySelector("#bus-view [aria-busy]")'); assert.equal(await b.evaluate('document.activeElement === document.querySelector("#bus-view h1")'), true, 'the navigation keeps heading focus'); await go('#/queue', 'Queue'); assert.match(await text('#bus-view .page-head'), /Rows move only with scripts\/mosaic queue move in a terminal\. Console has no button for it\./); await go('#/queue?state=done', 'Queue'); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-queue tbody tr")].map(e=>e.cells[0].textContent)'), ['7 Second row']); await go('#/queue/6', 'Row 6'); assert.equal(await text('#bus-view .s1-cmd code'), 'scripts/mosaic queue move 6 --op --by '); await go('#/business', 'Business acme'); assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view [aria-labelledby=bz-about] dt")].find(e=>e.textContent==="Arbiters").nextElementSibling.textContent'), 'pm (delivery)'); for (const hash of ['#/queue', '#/queue/6', '#/business', '#/settings']) { await go(hash, { '#/queue': 'Queue', '#/queue/6': 'Row 6', '#/business': 'Business', '#/settings': 'Settings' }[hash]); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view button, #bus-view form, #bus-view input")].filter(e=>!e.matches("button[data-copy], button[data-retry]")).length'), 0, `${hash}: no control but Copy and Read again`); assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view button")].some(e=>/move|resolve|approve|save|edit/i.test(e.textContent))'), false, hash); } // The palette lists queue rows from the last queue read, as text, and still does once its // inbox and tasks reads have answered. Over a server with no bus they were refusals that // forgot the queue rows, and the check passed only when it ran before they returned. const asked = await b.evaluate('window.requests.length'); await b.key('k', 'KeyK', 2); await wait(`window.requests.slice(${asked}).filter(([,u])=>/\\/api\\/bus\\/(inbox|tasks)$/.test(u)).length===2 && window.bodies.length===window.requests.length`); await wait(`${palette}.includes("#/queue/7")`); assert.equal(await count('#cmdk-list #q-b'), 0); await closePalette(); // Settings: appearance is the one control, mirrored with the command bar and kept in this browser. await go('#/settings', 'Settings'); assert.equal(await text('#bus-view [aria-labelledby=st-notify] dd code'), 'jason-dm'); assert.match(await text('#bus-view [aria-labelledby=st-about]'), /Release0\.0\.99/); assert.match(await text('#bus-view [aria-labelledby=st-about]'), new RegExp(`Addresshttp://127\\.0\\.0\\.1:${web.address().port}/`)); await b.evaluate('{const m=document.querySelector("#bus-view select[data-mirror=mode]");m.value="dark";m.dispatchEvent(new Event("change",{bubbles:true}))}'); await wait('document.querySelector("#mode").value==="dark"'); assert.equal(await b.evaluate('JSON.parse(localStorage.getItem("mosaic-console-appearance")).mode'), 'dark'); // The 10 s refresh redraws Settings and keeps focus on the mirror select (Darkwing D29). await b.evaluate('document.querySelector("#bus-view select[data-mirror=mode]").focus(); document.querySelector("#bus-view h1").dataset.old = "1"'); assert.equal(await b.evaluate('fireRefresh()'), 1, 'a refresh is due on #/settings'); await wait('!document.querySelector("#bus-view h1").dataset.old && !document.querySelector("#bus-view [aria-busy]")'); assert.equal(await b.evaluate('document.activeElement?.matches("#bus-view select[data-mirror=mode]") ?? false'), true, 'the refresh keeps focus on the mirror select'); 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); } }); test('seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module\'s words', { timeout: 180000 }, async t => { const s = seeded(t), repo = queueRepo(t); const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: consoleReads({ root: repo.root, env: repo.env, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }) }); const b = await browser(); try { const { wait, text, go } = await page(b, `http://127.0.0.1:${web.address().port}/`); const seen = async () => clean(await b.evaluate('document.documentElement.outerHTML + "\\n" + window.bodies.join("\\n")'), s.base, repo.base, repo.root, tmpdir()); await go('#/queue', 'Queue'); await seen(); await go('#/queue/6', 'Row 6'); assert.equal(await text('#bus-view dd.s1-q'), NOTE_SHOWN); await seen(); // After as the store keeps it, {id, when}: a link to the row and its condition (Filbert B1). const kv = (dt) => b.evaluate(`[...document.querySelectorAll("#bus-view [aria-labelledby=q-row] dt")].find(e=>e.textContent===${JSON.stringify(dt)})?.nextElementSibling?.innerHTML ?? null`); await go('#/queue/9', 'Row 9'); assert.equal(await kv('After'), '6 settled'); assert.match(await kv('Required'), /^yes( since |$)/); await go('#/queue/11', 'Row 11'); assert.equal(await kv('After'), '9 done'); assert.equal(await kv('Required'), null); await seen(); await go('#/business', 'Business acme'); assert.match(await text('#bus-view [aria-labelledby=bz-creds]'), /bot-acme-coder/); // The business file's arbiters object, as loadBusiness gives it (Darkwing 27186). assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view [aria-labelledby=bz-about] dt")].find(e=>e.textContent==="Arbiters").nextElementSibling.textContent'), 'pm (delivery), cto (technical)'); await seen(); await go('#/settings', 'Settings'); assert.equal(await text('#bus-view [aria-labelledby=st-notify] dd code'), 'jason-dm'); await seen(); assert.equal(await b.evaluate('document.body.innerText.includes("jason-dm.json")'), false); // A missing business file, then one whose parse error would quote a value. rmSync(join(s.configDir, 'businesses', 'acme.json')); await go('#/business', 'Nothing to read'); assert.equal(await text('#bus-view .banner.ref b code'), 'not-configured'); assert.equal(await text('#bus-view .banner.ref .source'), 'business file not found: /businesses/acme.json'); writeJson(join(s.configDir, 'businesses', 'acme.json'), '{"id": "acme", s3cret-value-xyz}'); await b.evaluate('document.querySelector("#refresh").click()'); await wait('/not valid JSON/.test(document.querySelector("#bus-view .banner.ref .source")?.textContent)'); assert.match(await text('#bus-view .banner.ref .source'), /^business file is not valid JSON \(\/businesses\/acme\.json\)/); assert.equal(await b.evaluate('document.documentElement.outerHTML.includes("s3cret-value-xyz") || window.bodies.some(x=>x.includes("s3cret-value-xyz"))'), false); await seen(); 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); } }); test('the server log names no config path when the system config refuses', { timeout: 30000 }, async t => { const base = realpathSync(mkdtempSync(join(tmpdir(), 'mosaic-webui-log-'))); t.after(() => rmSync(base, { recursive: true, force: true })); for (const [name, setup] of [['missing', () => {}], ['invalid', () => { mkdirSync(join(base, 'cfg'), { recursive: true }); writeFileSync(join(base, 'cfg', 'config.json'), '{"dataRoot": s3cret-value-xyz}'); }]]) { setup(); const child = spawn(process.execPath, [join(SRC, 'cli.mjs'), 'serve', '--port', '0'], { env: { ...process.env, MOSAIC_CONFIG: join(base, 'cfg', 'config.json') } }); let out = ''; child.stdout.on('data', c => { out += c; }); child.stderr.on('data', c => { out += c; }); const exited = new Promise(r => child.once('exit', r)); for (let i = 0; i < 200 && !out.includes('Ctrl-C') && child.exitCode === null; i++) await new Promise(r => setTimeout(r, 50)); child.kill('SIGTERM'); await exited; assert.match(out, /Bus: not configured \(/, `${name}: ${out}`); assert.equal(out.includes(base), false, `${name}: the log names the temporary directory: ${out}`); assert.equal(out.includes('s3cret-value-xyz'), false, `${name}: the log quotes the config`); } });