feat(webui): read-only Queue, Business and Settings views (#1543, row 54)
The Console gains three read-only views. Queue lists every row from
rows() in packages/queue (lead decision 83: the same lock-free read as
`queue list`, writing nothing), run in a child with a timeout and an
output cap; a row page shows the full row. Business shows names, an
allowlist of vars, arbiters, authority per action and credential
metadata (service, account, role, date, never a value, file or
variable). Settings shows RELEASE, the board origin and the notifier
binding. Every route is GET only, and every string in a body or
refusal passes a redactor: the config directory and dataRoot become
<config> and <dataRoot>, other absolute, ~/ and file:// paths <path>,
and 17 to 20 digit runs <id>. A queue-read that fails to start sends a
fixed message, never node's stderr.
Four fixes to HEAD behaviour, each with a test: the bus view's refresh
timer is cleared at render, a same-page refresh keeps focus on the H1,
#conv-pick is emptied when a conversation opens, and error() returns
focus to <main> only when something had focus. Filbert's T8 tests the
failed first read.
Dewey built it in two rounds. Round 1 (dba2429e) got changes from
Filbert (27185: After rendered [object Object], ~/ and :/ paths leaked)
and Darkwing (27186: Arbiters always none, queue-read import failure
leaked a file:// path and stack). Round 2 (afb2ae0e) was approved by
Filbert (27190) and Darkwing (27191, correction 27192). Sage's gate on
d64f434f plus the candidate: 13 package node suites 0 failed (queue
149, webui 39), every scripts/test-*.sh 0 failed (task 98/0 with
Docker, 26/0 without; release 14/0), build-tokens --check current.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
// Row 54 (#1543): the seeded fixture the reads and views tests share. A
|
||||
// business holds a Discord user id, token files, an environment variable
|
||||
// name and bot ids; a data root holds a notifier config and a Discord
|
||||
// binding with guild, channel and user ids and a token file; an agent's
|
||||
// model var holds a path. A queue row's note names an id and token paths in
|
||||
// the shapes real notes use (row 35: "~/.config/mosaic-dev/secrets/…").
|
||||
// None may reach a response, the page or a log, nor may the temporary
|
||||
// directory. Rows 9 and 11 have After entries in the stored {id, when} shape.
|
||||
import assert from 'node:assert/strict';
|
||||
import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { scratchRepo, genesisCommitted, mapText, MAP_ROWS } from '../../queue/tests/helpers.mjs';
|
||||
import { businessDoc, writeJson } from '../../business/tests/helpers.mjs';
|
||||
|
||||
export const SRC = join(import.meta.dirname, '..', 'src');
|
||||
export const SNOWFLAKES = ['123456789012345678', '223456789012345678', '323456789012345678', '423456789012345678', '523456789012345678'];
|
||||
export const day = n => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
export const NOTE = `token at /srv/secret/x.token for ${SNOWFLAKES[4]}; tokens 0600 under ~/.config/mosaic-dev/secrets/mosaic-stack (receipt file:/srv/secret/r.json)`;
|
||||
export const NOTE_SHOWN = 'token at <path> for <id>; tokens 0600 under <path> (receipt file:<path>)';
|
||||
|
||||
// A canonical scratch checkout with a committed queue, the reader script
|
||||
// beside a copy of packages/queue/src, and a RELEASE file.
|
||||
export function queueRepo(t) {
|
||||
const rows = MAP_ROWS.map(r => r.id === 6 ? { ...r, note: NOTE } : r.id === 11 ? { ...r, after: [{ id: 9, when: 'done' }] } : r);
|
||||
const repo = scratchRepo(t, { map: mapText(rows) });
|
||||
genesisCommitted(repo);
|
||||
mkdirSync(join(repo.root, 'packages/webui/src'), { recursive: true });
|
||||
cpSync(join(SRC, 'queue-read.mjs'), join(repo.root, 'packages/webui/src/queue-read.mjs'));
|
||||
writeFileSync(join(repo.root, 'RELEASE'), '0.0.99\n');
|
||||
return repo;
|
||||
}
|
||||
|
||||
// The business, notifier and binding files under one temporary base.
|
||||
export function seeded(t, { business = true, notify = true } = {}) {
|
||||
const base = realpathSync(mkdtempSync(join(tmpdir(), 'mosaic-webui-reads-')));
|
||||
t.after(() => rmSync(base, { recursive: true, force: true }));
|
||||
const configDir = join(base, 'config'), dataRoot = join(base, 'data');
|
||||
const doc = businessDoc(base);
|
||||
doc.vars['human.discordUserId'] = SNOWFLAKES[0];
|
||||
doc.roles.coder.credentials.gitea = { env: 'CODER_GITEA_SECRET_ENV', rotateBy: day(-1) };
|
||||
doc.roles.coder.credentials.vikunja.expires = day(3);
|
||||
doc.roles.reviewer.credentials.vikunja.expires = day(-2);
|
||||
doc.roles.reviewer.vars = { model: '/srv/secret/models/local.gguf' };
|
||||
if (business) writeJson(join(configDir, 'businesses', 'acme.json'), doc);
|
||||
if (notify) writeJson(join(dataRoot, 'notify', 'acme', 'notify.json'), { notifyVersion: 1, binding: 'jason-dm' });
|
||||
writeJson(join(dataRoot, 'discord', 'jason-dm.json'), {
|
||||
bindingVersion: 1, name: 'jason-dm', seat: 'sage', guildId: SNOWFLAKES[1], guildName: 'g', botUserId: SNOWFLAKES[2],
|
||||
tokenFile: join(base, 'secrets', 'discord.token'), channels: [{ id: SNOWFLAKES[3], name: 'c', mode: 'open' }], users: [{ id: SNOWFLAKES[0], name: 'jason' }],
|
||||
});
|
||||
const system = { configVersion: 1, environment: 'development', dataRoot, execution: { backend: 'docker', provider: 'zai', model: 'glm-5.3-flash', adapter: 'mock' } };
|
||||
return { base, configDir, dataRoot, system, doc };
|
||||
}
|
||||
|
||||
// Nothing secret, no id, no temporary path, in any body.
|
||||
export function clean(text, ...bases) {
|
||||
for (const b of bases) assert.equal(text.includes(b), false, `response names ${b}`);
|
||||
for (const s of SNOWFLAKES) assert.equal(text.includes(s), false, `response carries id ${s}`);
|
||||
for (const s of ['placeholder-not-a-token', 'CODER_GITEA_SECRET_ENV', '"file"', '"env"', '"botId"', 'discordUserId', '"tokenFile"', '"guildId"', '"channels"', '/srv/secret', '~/', 'secrets/mosaic-stack']) assert.equal(text.includes(s), false, `response carries ${s}`);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Row 54 (#1543): the Queue, Business and Settings reads over the seeded
|
||||
// fixture in reads-fixture.mjs. No response may carry a secret, an id or
|
||||
// the temporary directory.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { consoleReads, credentialState, queueReader, redactor } from '../src/reads.mjs';
|
||||
import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs';
|
||||
import { SRC, SNOWFLAKES, NOTE_SHOWN, day, queueRepo, seeded, clean } from './reads-fixture.mjs';
|
||||
|
||||
async function serve(t, reads) {
|
||||
const server = await startServer({ port: 0, reads });
|
||||
t.after(() => server.close());
|
||||
return `http://127.0.0.1:${server.address().port}`;
|
||||
}
|
||||
const get = async (base, path, init) => { const r = await fetch(base + path, init); return { status: r.status, text: await r.text() }; };
|
||||
|
||||
test('redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote', () => {
|
||||
const r = redactor({ configDir: '/home/u/.config/mosaic-dev', dataRoot: '/home/u/.mosaic-dev' });
|
||||
assert.equal(r('business file not found: /home/u/.config/mosaic-dev/businesses/a.json'), 'business file not found: <config>/businesses/a.json');
|
||||
assert.equal(r('no notifier config at /home/u/.mosaic-dev/notify/a/notify.json; write'), 'no notifier config at <dataRoot>/notify/a/notify.json; write');
|
||||
assert.equal(r('file /run/x.token, see https://git.example/a'), 'file <path>, see https://git.example/a');
|
||||
assert.equal(r('/home/u/.config/mosaic-devX/y'), '<path>');
|
||||
assert.equal(r(`user ${SNOWFLAKES[0]} ok`), 'user <id> ok');
|
||||
// ~/ paths, and a path after `:` or `<` (Filbert R1 and Sage, #1543 comment 27185). A URL keeps its path.
|
||||
assert.equal(r('tokens 0600 under ~/.config/mosaic-dev/secrets/mosaic-stack (lead decision 74)'), 'tokens 0600 under <path> (lead decision 74)');
|
||||
assert.equal(r('no `~/.mosaic` changes; key=~/k'), 'no `<path>` changes; key=<path>');
|
||||
assert.equal(r('file:/x, file:///srv/y and </srv/z.token>'), 'file:<path>, file:<path> and <<path>>');
|
||||
assert.equal(r('https://git.example/a, http://127.0.0.1:3456 and http://h:80/p'), 'https://git.example/a, http://127.0.0.1:3456 and http://h:80/p');
|
||||
assert.equal(r('a~/b, ~ and agents/sage/work/'), 'a~/b, ~ and agents/sage/work/');
|
||||
const quoted = r('business file is not valid JSON (/home/u/.config/mosaic-dev/businesses/a.json): Unexpected token \'s\', "s3cret-value" is not valid JSON');
|
||||
assert.equal(quoted.includes('s3cret-value'), false);
|
||||
assert.match(quoted, /^business file is not valid JSON \(<config>\/businesses\/a\.json\): the parser quoted/);
|
||||
});
|
||||
|
||||
test('credential state from the date alone, as the bus states it', () => {
|
||||
const now = Date.parse('2026-10-10T12:00:00Z');
|
||||
assert.equal(credentialState('gitea', '2026-10-10', now), 'rotation-due');
|
||||
assert.equal(credentialState('gitea', '2026-10-11', now), 'valid');
|
||||
assert.equal(credentialState('vikunja', '2026-10-10', now), 'expired');
|
||||
assert.equal(credentialState('vikunja', '2026-10-16', now), 'expiring');
|
||||
assert.equal(credentialState('vikunja', '2026-10-18', now), 'valid');
|
||||
});
|
||||
|
||||
test('queue: rows, one row, ids and methods, notes; no path or id from a row', async t => {
|
||||
const repo = queueRepo(t);
|
||||
const base = await serve(t, consoleReads({ root: repo.root, env: repo.env, rolesDir: REPO_ROLES }));
|
||||
const list = await get(base, '/api/queue');
|
||||
assert.equal(list.status, 200, list.text);
|
||||
const body = JSON.parse(list.text);
|
||||
assert.deepEqual(body.rows.map(r => r.id), [1, 6, 8, 9, 11]);
|
||||
const six = body.rows.find(r => r.id === 6);
|
||||
assert.deepEqual([six.state, six.owner, six.reviewers, six.brief], ['in-progress', 'darkwing', ['filbert'], { path: 'docs/plans/brief-a.md', anchor: 'Row six' }]);
|
||||
assert.equal(six.note, NOTE_SHOWN);
|
||||
assert.equal(Object.hasOwn(six, 'gate'), false);
|
||||
assert.ok(Array.isArray(body.notes));
|
||||
clean(list.text, repo.base);
|
||||
const one = await get(base, '/api/queue/9');
|
||||
assert.equal(one.status, 200);
|
||||
const nine = JSON.parse(one.text).row;
|
||||
assert.deepEqual([nine.id, nine.gate, nine.after], [9, 'filbert approves', [{ id: 6, when: 'settled' }]]);
|
||||
clean(one.text, repo.base);
|
||||
assert.deepEqual(JSON.parse((await get(base, '/api/queue/11')).text).row.after, [{ id: 9, when: 'done' }]);
|
||||
assert.equal((await get(base, '/api/queue/7')).status, 404);
|
||||
for (const bad of ['abc', '0', '01', '1234567', '9/x', '-1']) assert.equal((await get(base, `/api/queue/${bad}`)).status, 400, bad);
|
||||
for (const path of ['/api/queue', '/api/queue/9', '/api/business', '/api/settings']) {
|
||||
assert.equal((await get(base, path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, 405, path);
|
||||
}
|
||||
assert.equal((await get(base, '/api/queue', { headers: { origin: 'https://evil.example' } })).status, 403);
|
||||
});
|
||||
|
||||
test('queue: a refused read fails closed with the store\'s text, redacted', async t => {
|
||||
const repo = queueRepo(t);
|
||||
const base = await serve(t, consoleReads({ root: repo.root, env: { ...repo.env, GIT_DIR: join(repo.root, '.git') }, rolesDir: REPO_ROLES }));
|
||||
const r = await get(base, '/api/queue');
|
||||
assert.equal(r.status, 503);
|
||||
const body = JSON.parse(r.text);
|
||||
assert.equal(body.error, 'queue-refused');
|
||||
assert.match(body.message, /GIT_DIR/);
|
||||
clean(r.text, repo.base);
|
||||
assert.equal((await get(base, '/api/queue/6')).status, 503);
|
||||
});
|
||||
|
||||
test('queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack', { timeout: 60000 }, async t => {
|
||||
// Darkwing 27186: a half-written store.mjs used to send Node's own error, file:// path and stack included.
|
||||
for (const [name, breakStore] of [['syntax error', p => writeFileSync(p, 'export const rows = (;\n')], ['missing', p => rmSync(p)]]) {
|
||||
const repo = queueRepo(t);
|
||||
breakStore(join(repo.root, 'packages/queue/src/store.mjs'));
|
||||
const child = spawnSync(process.execPath, [join(repo.root, 'packages/webui/src/queue-read.mjs')], { cwd: repo.root, env: repo.env, encoding: 'utf8' });
|
||||
assert.deepEqual([child.status, child.stdout, child.stderr], [1, '', 'the queue read failed\n'], name);
|
||||
const base = await serve(t, consoleReads({ root: repo.root, env: repo.env, rolesDir: REPO_ROLES }));
|
||||
const r = await get(base, '/api/queue');
|
||||
assert.equal(r.status, 503, name);
|
||||
assert.deepEqual(JSON.parse(r.text), { error: 'read-failed', message: 'the queue read failed (exit 1)' }, name);
|
||||
clean(r.text, repo.base, repo.root);
|
||||
}
|
||||
// Only exit 2 forwards the child's text; any other exit is a fixed message.
|
||||
const repo = queueRepo(t), script = join(repo.root, 'packages/webui/src/queue-read.mjs');
|
||||
const fake = (body, opts = {}) => { writeFileSync(script, body); return queueReader({ root: repo.root, env: repo.env, ...opts })(); };
|
||||
await assert.rejects(fake('process.stderr.write("Error: boom\\n at main (file:///srv/q/x.mjs:1:1)\\n"); process.exitCode = 3;\n'), { code: 'read-failed', message: 'the queue read failed (exit 3)' });
|
||||
await assert.rejects(fake('setInterval(() => {}, 1000);\n', { timeoutMs: 300 }), { code: 'read-failed', message: 'the queue read did not finish in time' });
|
||||
await assert.rejects(fake('process.stdout.write("x".repeat(65536));\n', { maxBytes: 1024 }), { code: 'invalid-response', message: 'the queue read printed too much' });
|
||||
});
|
||||
|
||||
test('business: names, vars on the allowlist, authority, credential metadata only', async t => {
|
||||
const s = seeded(t);
|
||||
const base = await serve(t, consoleReads({ root: SRC, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }));
|
||||
const r = await get(base, '/api/business');
|
||||
assert.equal(r.status, 200, r.text);
|
||||
clean(r.text, s.base);
|
||||
const b = JSON.parse(r.text).business;
|
||||
assert.deepEqual([b.id, b.human, b.arbiters, b.projects], ['acme', 'jason', { delivery: 'pm', technical: 'cto' }, ['stack']]);
|
||||
assert.deepEqual(b.vars, { 'tracker.baseUrl': 'http://127.0.0.1:3456', 'gitea.baseUrl': 'https://git.example', 'tracker.pollSeconds': 60 });
|
||||
assert.deepEqual(b.instances.map(i => [i.instance, i.definition, i.holder]), [['pm', 'pm', 'sage'], ['cto', 'cto', 'darkwing'], ['coder', 'coder', null], ['reviewer', 'reviewer', null]]);
|
||||
assert.deepEqual(b.instances.find(i => i.instance === 'coder').vars, { harness: 'pi', 'limits.network': 'none' });
|
||||
// An allowlisted var is scrubbed too (Filbert T1).
|
||||
assert.deepEqual(b.instances.find(i => i.instance === 'reviewer').vars, { model: '<path>' });
|
||||
for (const i of b.instances) {
|
||||
assert.deepEqual(Object.keys(i.authority), b.actions);
|
||||
assert.ok(Object.values(i.authority).every(v => ['within', 'cross', 'gated'].includes(v)));
|
||||
}
|
||||
assert.equal(b.instances.find(i => i.instance === 'pm').authority['role.launch'], 'within');
|
||||
assert.deepEqual(b.launch, { by: 'pm', instances: ['coder', 'reviewer'], max: { opus: 2, sonnet: 4 } });
|
||||
const cred = (role, service) => b.credentials.find(c => c.role === role && c.service === service);
|
||||
assert.deepEqual(Object.keys(cred('pm', 'gitea')).sort(), ['account', 'date', 'dateKind', 'role', 'service', 'state']);
|
||||
assert.deepEqual(cred('coder', 'gitea'), { service: 'gitea', account: null, role: 'coder', dateKind: 'rotateBy', date: day(-1), state: 'rotation-due' });
|
||||
assert.deepEqual(cred('coder', 'vikunja'), { service: 'vikunja', account: 'bot-acme-coder', role: 'coder', dateKind: 'expires', date: day(3), state: 'expiring' });
|
||||
assert.equal(cred('reviewer', 'vikunja').state, 'expired');
|
||||
assert.deepEqual(cred('tracker sync', 'vikunja'), { service: 'vikunja', account: 'bot-acme-sync', role: 'tracker sync', dateKind: 'expires', date: '2099-01-01', state: 'valid' });
|
||||
assert.equal(b.credentials.length, 9);
|
||||
});
|
||||
|
||||
test('business: missing or invalid file refuses with the module\'s text, redacted', async t => {
|
||||
const s = seeded(t, { business: false });
|
||||
const reads = consoleReads({ root: SRC, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' });
|
||||
const base = await serve(t, reads);
|
||||
let r = await get(base, '/api/business');
|
||||
assert.equal(r.status, 503);
|
||||
assert.deepEqual(JSON.parse(r.text), { error: 'not-configured', message: 'business file not found: <config>/businesses/acme.json' });
|
||||
// Invalid JSON whose parse error would quote a secret-looking value.
|
||||
writeJson(join(s.configDir, 'businesses', 'acme.json'), '{"id": "acme", s3cret-value-xyz}');
|
||||
r = await get(base, '/api/business');
|
||||
assert.equal(r.status, 503);
|
||||
assert.match(JSON.parse(r.text).message, /^business file is not valid JSON \(<config>\/businesses\/acme\.json\)/);
|
||||
assert.equal(r.text.includes('s3cret-value-xyz'), false);
|
||||
clean(r.text, s.base);
|
||||
// Valid JSON, invalid business: the validator's message names the file.
|
||||
writeJson(join(s.configDir, 'businesses', 'acme.json'), { ...s.doc, launch: { ...s.doc.launch, by: 'nobody' } });
|
||||
r = await get(base, '/api/business');
|
||||
assert.equal(r.status, 503);
|
||||
assert.match(JSON.parse(r.text).message, /launch\.by names nobody/);
|
||||
clean(r.text, s.base);
|
||||
});
|
||||
|
||||
test('business without --business: the live bus host, else no-bus-host; no system config: not-configured', async t => {
|
||||
const s = seeded(t);
|
||||
let base = await serve(t, consoleReads({ root: SRC, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES }));
|
||||
let r = await get(base, '/api/business');
|
||||
assert.equal(r.status, 503);
|
||||
assert.equal(JSON.parse(r.text).error, 'no-bus-host');
|
||||
base = await serve(t, consoleReads({ root: SRC, system: null, configDir: s.configDir, rolesDir: REPO_ROLES }));
|
||||
r = await get(base, '/api/business');
|
||||
assert.equal(JSON.parse(r.text).error, 'not-configured');
|
||||
base = await serve(t, null);
|
||||
for (const path of ['/api/business', '/api/queue', '/api/queue/1']) assert.equal(JSON.parse((await get(base, path)).text).error, 'not-configured', path);
|
||||
});
|
||||
|
||||
test('settings: release, board, loopback address, notifier binding name only', async t => {
|
||||
const s = seeded(t);
|
||||
const repo = queueRepo(t);
|
||||
let base = await serve(t, consoleReads({ root: repo.root, env: repo.env, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }));
|
||||
let r = await get(base, '/api/settings');
|
||||
assert.equal(r.status, 200);
|
||||
const body = JSON.parse(r.text);
|
||||
assert.deepEqual({ ...body, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: 'http://127.0.0.1:7331', address: `${base}/`, at: undefined });
|
||||
clean(r.text, s.base, repo.base);
|
||||
// No notifier config: still 200, so appearance works; the reason is redacted.
|
||||
const bare = seeded(t, { notify: false });
|
||||
base = await serve(t, consoleReads({ root: repo.root, env: repo.env, system: bare.system, configDir: bare.configDir, rolesDir: REPO_ROLES, business: 'acme' }));
|
||||
r = await get(base, '/api/settings');
|
||||
assert.equal(r.status, 200);
|
||||
const n = JSON.parse(r.text).notifier;
|
||||
assert.equal(n.refused, 'not-configured');
|
||||
assert.match(n.message, /^no notifier config at <dataRoot>\/notify\/acme\/notify\.json/);
|
||||
clean(r.text, bare.base, repo.base);
|
||||
base = await serve(t, null);
|
||||
r = await get(base, '/api/settings');
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(JSON.parse(r.text).notifier.refused, 'not-configured');
|
||||
// --business with no system config: refused in Console's words, not Node's (Filbert N1).
|
||||
base = await serve(t, consoleReads({ root: repo.root, env: repo.env, system: null, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }));
|
||||
r = await get(base, '/api/settings');
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(JSON.parse(r.text).notifier, { refused: 'not-configured', message: 'the Console has no system config, so it knows no notifier config' });
|
||||
});
|
||||
@@ -131,6 +131,21 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
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, a failed first read: drawn empty and saying why, not left as the loading skeleton. A reload resets window.errors, so check it first.
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
brd.unavailable(); await b.navigate(base); await wait('!document.querySelector("#error").hidden');
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
|
||||
assert.match(await text('#error'), /No board data loaded\./);
|
||||
assert.equal(await count('#sessions [aria-busy]'), 0, 'no skeleton after a failed first read');
|
||||
assert.equal(await text('#sessions'), '');
|
||||
assert.equal(await text('#waiting'), '');
|
||||
assert.deepEqual(await b.evaluate('["waiting-count","seen-count","session-count"].map(id=>document.getElementById(id).textContent)'), ['–', '–', '–']);
|
||||
assert.equal(await text('#status'), 'Not read: the read failed');
|
||||
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('#projects'), 'Not read: the read failed.');
|
||||
brd.empty(); await refresh(); await wait('document.querySelector("#error").hidden');
|
||||
|
||||
// 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\./);
|
||||
@@ -170,8 +185,10 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
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.
|
||||
// the refused scan, and the footer says what the banner says. With nothing focused, focus stays put.
|
||||
await b.evaluate('document.activeElement.blur()');
|
||||
brd.unavailable(); await refresh();
|
||||
assert.equal(await b.evaluate('document.activeElement === document.body'), true, 'a failure with nothing focused leaves focus alone');
|
||||
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');
|
||||
@@ -196,6 +213,13 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
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);
|
||||
// Fix to HEAD behaviour: a failed catalogue read leaves no session from the last one in the picker.
|
||||
assert.equal(await count('#conv-pick option'), 1);
|
||||
brd.fail(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||||
await wait('document.querySelector("#conv-status").textContent.startsWith("History unavailable")');
|
||||
assert.equal(await count('#conv-pick option'), 0, 'no stale session in the picker');
|
||||
brd.registered(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||||
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
|
||||
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);
|
||||
@@ -203,6 +227,8 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
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);
|
||||
// Fix to HEAD behaviour: focus went back to History, which the refusal removed; it lands on the page, not <body>.
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'main', 'focus after a refusal closes the conversation');
|
||||
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.
|
||||
@@ -234,7 +260,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
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);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 7);
|
||||
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]);
|
||||
@@ -429,7 +455,7 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }
|
||||
}
|
||||
|
||||
// After a refusal the palette lists only the views: no kept decision or task.
|
||||
const views = ['#/', '#/inbox', '#/tasks', '#/agents'];
|
||||
const views = ['#/', '#/inbox', '#/tasks', '#/agents', '#/queue', '#/business', '#/settings'];
|
||||
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/);
|
||||
@@ -452,7 +478,7 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }
|
||||
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']);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll("#cmdk-list [data-href] span:first-child")].slice(${views.length}).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');
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// 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 <b id="q-b">views</b>', 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 <dataRoot>/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}: <config>/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 <b id="q-b">views</b>', '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 <body>.
|
||||
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 <state> --op <op> --by <seat>');
|
||||
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'), '<a href="#/queue/6">6</a> settled');
|
||||
assert.match(await kv('Required'), /^yes( since |$)/);
|
||||
await go('#/queue/11', 'Row 11');
|
||||
assert.equal(await kv('After'), '<a href="#/queue/9">9</a> 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: <config>/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 \(<config>\/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`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user