// 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`);
}
});