438 lines
35 KiB
Diff
438 lines
35 KiB
Diff
--- a/packages/webui/README.md
|
|
+++ b/packages/webui/README.md
|
|
@@ -157,7 +157,8 @@
|
|
with a Copy button and has no button that moves a row. The brief is shown
|
|
as its path and anchor, because Console serves no repository files.
|
|
- `#/business` shows the business named by `--business`, else the running
|
|
- bus host's business: the human, arbiters, projects, launch rules, the vars
|
|
+ bus host's business: the human, the arbiters as instance and kind ("pm
|
|
+ (delivery), cto (technical)"), projects, launch rules, the vars
|
|
on an allowlist, each role instance with its holder and harness, the
|
|
authority of each instance for each action as the business package
|
|
classifies it (`resolveInstance`, `classify`), and credentials as
|
|
@@ -173,9 +174,14 @@
|
|
response, page or log carries one, a file path or a Discord channel, guild or
|
|
user id. The server redacts every message and string field before it answers
|
|
(`redactor` in `src/reads.mjs`): the config directory becomes `<config>`, the
|
|
-data root `<dataRoot>`, any other absolute path `<path>`, a 17 to 20 digit run
|
|
-`<id>`, and a JSON parse error's quote of the file is dropped. The startup
|
|
-log goes through the same redactor.
|
|
+data root `<dataRoot>`, any other absolute or `~/` path `<path>`, a 17 to 20
|
|
+digit run `<id>`, and a JSON parse error's quote of the file is dropped. The
|
|
+startup log goes through the same redactor. A path starts at the beginning
|
|
+of the text or after a space, an opening bracket, a quote, `=`, `,`, `<` or
|
|
+`:`, so `file:/x` and `file:///x` are redacted and `https://host/x` is not.
|
|
+Known gap: a path stops at the first space, so a path containing a space is
|
|
+redacted only up to that space and the rest shows. A bare `~` or `~user/`
|
|
+is not treated as a path, and neither is a relative path.
|
|
|
|
## Data and boundaries
|
|
|
|
@@ -211,7 +217,12 @@
|
|
400. The queue is read in a child process (`src/queue-read.mjs`) through
|
|
`rows()` from `packages/queue/src/store.mjs` (lead decision 83): the same
|
|
read as `queue list`, with no write and no network. Like `list`, it takes
|
|
-the queue lock only to recheck a disagreement before reporting it. The business
|
|
+the queue lock only to recheck a disagreement before reporting it. The child
|
|
+imports the store inside its `try`, so a store that fails to load reports
|
|
+"the queue read failed". Only the store's exit 2 (invalid data or refused)
|
|
+forwards the child's text, as `queue-refused`; any other exit is the fixed
|
|
+`read-failed` message "the queue read failed (exit N)", because that stderr
|
|
+may be Node's own, with a `file://` path and a stack. The business
|
|
read uses `packages/business` in process. No read writes, calls out or adds a
|
|
dependency. Answers carry `at`; failures are `{ error, message }` with 503
|
|
for `not-configured`, `no-bus-host`, `queue-refused` and `read-failed`, 404
|
|
@@ -282,16 +293,26 @@
|
|
palette's own reads and from the board page, and that palette labels are text.
|
|
|
|
Row 54's tests share the seeded fixture in `reads-fixture.mjs`: a scratch
|
|
-queue whose row note names a token path and an id, and a business, notifier
|
|
-config and Discord binding that hold token files, an environment variable
|
|
-name, and guild, channel, bot and user ids. `reads.test.mjs` checks each
|
|
+queue whose row note names an id and token paths shaped like real notes
|
|
+(absolute, `~/` and after `file:`; the store refuses `<` in a note, so the
|
|
+`<…>` case is in the redactor test), and a business,
|
|
+notifier config and Discord binding that hold token files, an environment
|
|
+variable name, an agent `model` var holding a path, and guild, channel, bot
|
|
+and user ids. Row 9's After entry has the `{id, when}` shape the real queue
|
|
+stores. `reads.test.mjs` checks each
|
|
route's body and status, the redactor, the credential state, methods and
|
|
-Origin, and that no body carries any seeded value or the temporary directory.
|
|
+Origin, and that no body carries any seeded value or the temporary directory;
|
|
+that a `store.mjs` with a syntax error, or none, gives the fixed message with
|
|
+no path; and that the child's timeout, output cap and non-2 exits hold, over a
|
|
+fake child script.
|
|
`views.test.mjs` walks Queue, a queue row, Business and Settings through the
|
|
five states over a stub read, with each refusal code the view can meet, at
|
|
400px; checks that a refusal leaves no queue row in the palette, that no view
|
|
has a control but Copy and Read again, and that the row page shows the
|
|
-`queue move` command; then, over the seeded fixture, that neither the page nor
|
|
+`queue move` command, that the 10 s refresh keeps focus on a Settings mirror
|
|
+select, and that the palette keeps its queue rows after its own inbox and tasks
|
|
+reads answer; then, over the seeded fixture, that the Arbiters line reads the
|
|
+business file's object, that neither the page nor
|
|
any response it read carries a seeded value, that a missing or invalid
|
|
business file shows the module's text, and that the startup log names no
|
|
config path.
|
|
--- a/packages/webui/src/public/bus.js
|
|
+++ b/packages/webui/src/public/bus.js
|
|
@@ -345,7 +345,11 @@
|
|
const QSTATE = { done: 'ok', blocked: 'danger', 'waiting-on-jason': 'attn', 'in-review': 'attn', 'in-progress': 'attn' };
|
|
const qState = s => badge(s, QSTATE[s] || 'muted');
|
|
const qLink = id => Number.isInteger(id) ? `<a href="#/queue/${esc(id)}">${esc(id)}</a>` : txt(id);
|
|
+ // An after entry is {id, when}, as QUEUE.md writes it: "53 done".
|
|
+ const afterRef = a => a && typeof a === 'object' ? `${qLink(a.id)}${a.when ? ` ${txt(a.when)}` : ''}` : qLink(a);
|
|
const names = list => Array.isArray(list) && list.length ? list.map(txt).join(', ') : none;
|
|
+ // The business file's arbiters are {delivery, technical}, each a role instance: "pm (delivery)".
|
|
+ const arbiterRefs = a => a && typeof a === 'object' && !Array.isArray(a) ? Object.entries(a).map(([kind, who]) => `${txt(who)} (${txt(kind)})`).join(', ') || none : names(a);
|
|
const issueRefs = list => Array.isArray(list) && list.length ? list.map(n => `#${txt(n)}`).join(', ') : none;
|
|
// Console serves no repository files, so the brief is its path and anchor, as the row names it.
|
|
const briefRef = b => b?.path ? `<code>${txt(b.path)}${b.anchor ? `#${txt(b.anchor)}` : ''}</code>` : none;
|
|
@@ -377,7 +381,7 @@
|
|
const cmd = `scripts/mosaic queue move ${id} <state> --op <op> --by <seat>`;
|
|
return `${head(`Row ${esc(id)}: ${txt(r.piece)}`, `${qState(r.state)}${r.previousState ? ` <span class="small muted">was ${txt(r.previousState)}</span>` : ''} ${readLine()}`)}
|
|
${storeNotes(doc.notes)}
|
|
- <section class="panel" aria-labelledby="q-row"><h2 id="q-row">Row</h2><dl class="kv"><dt>Owner</dt><dd>${txt(r.owner) || none}</dd><dt>Reviewers</dt><dd>${names(r.reviewers)}</dd><dt>Gate</dt><dd>${txt(r.gateOwner) || none}</dd><dt>Issues</dt><dd>${issueRefs(r.issues)}</dd><dt>Closes</dt><dd>${issueRefs(r.closes)}</dd><dt>Brief</dt><dd>${briefRef(r.brief)}${r.brief?.blob ? ` <span class="source">blob ${txt(String(r.brief.blob).slice(0, 12))}</span>` : ''}</dd><dt>After</dt><dd>${Array.isArray(r.after) && r.after.length ? r.after.map(qLink).join(', ') : none}</dd><dt>Claim</dt><dd>${r.claim ? `${txt(r.claim.seat)} <span class="source">${txt(r.claim.op)}</span>` : none}</dd>${r.required ? `<dt>Required</dt><dd>${txt(r.required)}${r.requiredSince ? ` since ${when(r.requiredSince)}` : ''}</dd>` : ''}${r.blockedReason ? `<dt>Blocked</dt><dd>${txt(r.blockedReason)}</dd>` : ''}${r.note ? `<dt>Note</dt><dd class="s1-q">${txt(r.note)}</dd>` : ''}<dt>Created</dt><dd>${when(r.createdAt)}</dd><dt>Updated</dt><dd>${when(r.updatedAt)}${r.updatedBy ? ` by ${txt(r.updatedBy)}` : ''}</dd></dl></section>
|
|
+ <section class="panel" aria-labelledby="q-row"><h2 id="q-row">Row</h2><dl class="kv"><dt>Owner</dt><dd>${txt(r.owner) || none}</dd><dt>Reviewers</dt><dd>${names(r.reviewers)}</dd><dt>Gate</dt><dd>${txt(r.gateOwner) || none}</dd><dt>Issues</dt><dd>${issueRefs(r.issues)}</dd><dt>Closes</dt><dd>${issueRefs(r.closes)}</dd><dt>Brief</dt><dd>${briefRef(r.brief)}${r.brief?.blob ? ` <span class="source">blob ${txt(String(r.brief.blob).slice(0, 12))}</span>` : ''}</dd><dt>After</dt><dd>${Array.isArray(r.after) && r.after.length ? r.after.map(afterRef).join(', ') : none}</dd><dt>Claim</dt><dd>${r.claim ? `${txt(r.claim.seat)} <span class="source">${txt(r.claim.op)}</span>` : none}</dd>${r.required ? `<dt>Required</dt><dd>yes${Number.isFinite(Date.parse(r.requiredSince)) ? ` since ${when(r.requiredSince)}` : ''}</dd>` : ''}${r.blockedReason ? `<dt>Blocked</dt><dd>${txt(r.blockedReason)}</dd>` : ''}${r.note ? `<dt>Note</dt><dd class="s1-q">${txt(r.note)}</dd>` : ''}<dt>Created</dt><dd>${when(r.createdAt)}</dd><dt>Updated</dt><dd>${when(r.updatedAt)}${r.updatedBy ? ` by ${txt(r.updatedBy)}` : ''}</dd></dl></section>
|
|
<section aria-labelledby="q-review"><h2 id="q-review">Review <span class="count">${rounds.length}</span></h2>${rounds.length ? `<ol class="s1-plain">${rounds.map(round).join('')}</ol>` : '<p class="muted">No review round yet.</p>'}</section>
|
|
<section aria-labelledby="q-move"><h2 id="q-move">Moving this row</h2><p class="small muted">${moveHelp} Copy only puts the command on your clipboard; fill in the state, op and seat before you run it.</p>
|
|
<div class="s1-cmd"><code>${txt(cmd)}</code><button type="button" class="btn" data-copy="${esc(cmd)}" aria-label="Copy the move command for row ${esc(id)}">Copy</button></div><p class="small muted" id="copy-status"></p>
|
|
@@ -401,7 +405,7 @@
|
|
const refused = instances.filter(i => i.refused).map(i => `<li>${txt(i.instance)}: ${txt(i.refused)}</li>`).join('');
|
|
const credRows = creds.map(c => `<tr><td>${txt(c.service)}</td><td>${txt(c.account) || '<span class="muted">not named in the business file</span>'}</td><td>${txt(c.role)}</td><td>${txt(c.dateKind === 'expires' ? 'expires' : 'rotate by')} <time datetime="${esc(c.date)}">${txt(c.date)}</time></td><td>${badge(c.state, CRED[c.state] || 'muted')}</td></tr>`).join('');
|
|
return `${head(`Business ${txt(b.id)}`, `${readLine()} The business file is edited where it lives; Console only reads it.`)}
|
|
- <section class="panel" aria-labelledby="bz-about"><h2 id="bz-about">Business</h2><dl class="kv"><dt>Human</dt><dd>${txt(b.human) || none}</dd><dt>Arbiters</dt><dd>${names(b.arbiters)}</dd><dt>Projects</dt><dd>${names(b.projects)}</dd><dt>Launcher</dt><dd>${txt(launch.by) || none}</dd><dt>May launch</dt><dd>${names(launch.instances)}</dd><dt>At most</dt><dd>${Object.entries(launch.max || {}).map(([f, c]) => `${txt(c)} ${txt(f)}`).join(', ') || none}</dd></dl>
|
|
+ <section class="panel" aria-labelledby="bz-about"><h2 id="bz-about">Business</h2><dl class="kv"><dt>Human</dt><dd>${txt(b.human) || none}</dd><dt>Arbiters</dt><dd>${arbiterRefs(b.arbiters)}</dd><dt>Projects</dt><dd>${names(b.projects)}</dd><dt>Launcher</dt><dd>${txt(launch.by) || none}</dd><dt>May launch</dt><dd>${names(launch.instances)}</dd><dt>At most</dt><dd>${Object.entries(launch.max || {}).map(([f, c]) => `${txt(c)} ${txt(f)}`).join(', ') || none}</dd></dl>
|
|
<h3>Vars</h3>${kvRows(b.vars, 'No vars Console shows are set.')}</section>
|
|
<section aria-labelledby="bz-roles"><h2 id="bz-roles">Role instances <span class="count">${instances.length}</span></h2>${instances.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table class="s1-table s1-roles"><thead><tr>${['Instance', 'Definition', 'Holder', 'Harness'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${roleRows}</tbody></table></div>` : empty('This business has no role instances.', 'An instance shows here when the business file names one under roles.')}</section>
|
|
${instances.length ? `<section aria-labelledby="bz-auth"><h2 id="bz-auth">Authority</h2><p class="small muted">Within the role, cross-role (needs the other role), or gated (needs the human), as the business package classifies each action.</p>${matrix}${refused ? `<ul class="s1-plain">${refused}</ul>` : ''}</section>` : ''}
|
|
@@ -425,7 +429,7 @@
|
|
}
|
|
|
|
// Trail
|
|
- const GROUPS =[['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
|
|
+ const GROUPS = [['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
|
|
function trailList(rows, filter) {
|
|
const shown = filter === 'all' ? rows : rows.filter(r => r.group === filter);
|
|
if (!shown.length) return empty('Nothing of this kind in the trail.', 'Every step the stack takes on this subject writes a trail row.');
|
|
--- a/packages/webui/src/queue-read.mjs
|
|
+++ b/packages/webui/src/queue-read.mjs
|
|
@@ -3,14 +3,19 @@
|
|
// calls rows() in packages/queue (lead decision 83): the same unlocked read
|
|
// as `queue list`, which writes nothing. JSON on stdout; a refusal's message
|
|
// on stderr with the store's exit code.
|
|
+//
|
|
+// The store is imported inside the try, so a store.mjs that fails to load
|
|
+// (a half-written file in a shared checkout) prints "the queue read failed",
|
|
+// never Node's own message, which names the file:// path and a stack.
|
|
|
|
-import { rows } from '../../queue/src/store.mjs';
|
|
-import { QueueError } from '../../queue/src/errors.mjs';
|
|
-
|
|
+let QueueError = null;
|
|
try {
|
|
+ ({ QueueError } = await import('../../queue/src/errors.mjs'));
|
|
+ const { rows } = await import('../../queue/src/store.mjs');
|
|
const r = rows({});
|
|
process.stdout.write(`${JSON.stringify({ rows: r.rows, notes: r.err })}\n`);
|
|
} catch (err) {
|
|
- process.stderr.write(`${err instanceof QueueError ? err.message : 'the queue read failed'}\n`);
|
|
- process.exitCode = err instanceof QueueError && Number.isInteger(err.code) && err.code > 0 ? err.code : 1;
|
|
+ const refused = QueueError !== null && err instanceof QueueError;
|
|
+ process.stderr.write(`${refused ? err.message : 'the queue read failed'}\n`);
|
|
+ process.exitCode = refused && Number.isInteger(err.code) && err.code > 0 ? err.code : 1;
|
|
}
|
|
--- a/packages/webui/src/reads.mjs
|
|
+++ b/packages/webui/src/reads.mjs
|
|
@@ -10,7 +10,11 @@
|
|
//
|
|
// Every string a response carries passes through the redactor: the config
|
|
// directory and dataRoot become <config> and <dataRoot>, any other absolute
|
|
-// path <path>, and a 17 to 20 digit run (a Discord id) <id>.
|
|
+// or ~/ path <path>, and a 17 to 20 digit run (a Discord id) <id>. A path
|
|
+// starts at the beginning, a space, an opening bracket, a quote, `=`, `,`,
|
|
+// `<` or `:`, so `file:/x` and `file:///x` are paths and `https://host/x`
|
|
+// is not. A path stops at a space, so one with a space in it is redacted
|
|
+// only up to the space.
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { readFileSync } from 'node:fs';
|
|
@@ -41,7 +45,7 @@
|
|
return text => {
|
|
let s = unquote(String(text));
|
|
for (const [re, label] of known) s = s.replace(re, label);
|
|
- return s.replace(/(^|[\s('"`=,[{])\/[^\s'"`),;\]}]+/g, '$1<path>').replace(/(?<![\w.-])\d{17,20}(?![\w.-])/g, '<id>');
|
|
+ return s.replace(/(^|[\s('"`=,[{<]|:(?!\/\/[^/]))~?\/[^\s'"`),;\]}>]+/g, '$1<path>').replace(/(?<![\w.-])\d{17,20}(?![\w.-])/g, '<id>');
|
|
};
|
|
}
|
|
// Every string in a value, deep. Keys are left alone: they are fixed names or ids.
|
|
@@ -75,10 +79,12 @@
|
|
child.on('close', status => {
|
|
const said = Buffer.concat(err).toString('utf8').trim().slice(0, 2000);
|
|
if (status === null) return done(reject, new ReadError('read-failed', 'the queue read did not finish'));
|
|
- // 2 is the store's "invalid data or refused": fail closed. Anything
|
|
- // else failed to run; the view keeps its last read.
|
|
+ // 2 is the store's "invalid data or refused": fail closed with the
|
|
+ // store's text. Anything else failed to run, and its stderr may be
|
|
+ // Node's own (a path and a stack), so it is not shown; the view keeps
|
|
+ // its last read.
|
|
if (status === 2) return done(reject, new ReadError('queue-refused', said || 'the queue refused the read'));
|
|
- if (status !== 0) return done(reject, new ReadError('read-failed', said || `the queue read exited ${status}`));
|
|
+ if (status !== 0) return done(reject, new ReadError('read-failed', `the queue read failed (exit ${status})`));
|
|
try {
|
|
const doc = JSON.parse(Buffer.concat(out).toString('utf8'));
|
|
if (!Array.isArray(doc?.rows) || !Array.isArray(doc?.notes)) throw new Error('shape');
|
|
@@ -193,6 +199,7 @@
|
|
let id = null, notifier;
|
|
try {
|
|
id = businessId({ business, dataRoot });
|
|
+ if (!dataRoot) throw new ReadError('not-configured', 'the Console has no system config, so it knows no notifier config');
|
|
notifier = { binding: readNotifyConfig(dataRoot, id) };
|
|
} catch (err) {
|
|
notifier = { refused: err instanceof ReadError ? err.code : 'not-configured', message: err.message };
|
|
--- a/packages/webui/tests/reads-fixture.mjs
|
|
+++ b/packages/webui/tests/reads-fixture.mjs
|
|
@@ -1,9 +1,11 @@
|
|
// 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. A queue row's
|
|
-// note names a token path and an id. None may reach a response, the page or
|
|
-// a log, nor may the temporary directory.
|
|
+// 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';
|
|
@@ -15,10 +17,13 @@
|
|
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: `token at /srv/secret/x.token for ${SNOWFLAKES[4]}` } : r);
|
|
+ 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 });
|
|
@@ -37,6 +42,7 @@
|
|
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'), {
|
|
@@ -51,5 +57,5 @@
|
|
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']) assert.equal(text.includes(s), false, `response carries ${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}`);
|
|
}
|
|
--- a/packages/webui/tests/reads.test.mjs
|
|
+++ b/packages/webui/tests/reads.test.mjs
|
|
@@ -3,11 +3,13 @@
|
|
// 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, redactor } from '../src/reads.mjs';
|
|
+import { consoleReads, credentialState, queueReader, redactor } from '../src/reads.mjs';
|
|
import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs';
|
|
-import { SRC, SNOWFLAKES, day, queueRepo, seeded, clean } from './reads-fixture.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 });
|
|
@@ -23,6 +25,12 @@
|
|
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/);
|
|
@@ -46,7 +54,7 @@
|
|
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, 'token at <path> for <id>');
|
|
+ assert.equal(six.note, NOTE_SHOWN);
|
|
assert.equal(Object.hasOwn(six, 'gate'), false);
|
|
assert.ok(Array.isArray(body.notes));
|
|
clean(list.text, repo.base);
|
|
@@ -55,6 +63,7 @@
|
|
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']) {
|
|
@@ -75,6 +84,27 @@
|
|
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' }));
|
|
@@ -86,6 +116,8 @@
|
|
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)));
|
|
@@ -158,4 +190,9 @@
|
|
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' });
|
|
});
|
|
--- a/packages/webui/tests/views.test.mjs
|
|
+++ b/packages/webui/tests/views.test.mjs
|
|
@@ -15,7 +15,7 @@
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { consoleReads, ReadError } from '../src/reads.mjs';
|
|
import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs';
|
|
-import { SRC, queueRepo, seeded, clean } from './reads-fixture.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.
|
|
@@ -62,11 +62,19 @@
|
|
|
|
test('Queue, Business and Settings in their five states, with no move button', { timeout: 180000 }, async () => {
|
|
const s = stubReads();
|
|
- const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: s.reads });
|
|
+ // 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],
|
|
@@ -114,7 +122,7 @@
|
|
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 b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
|
+ 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}`);
|
|
@@ -157,16 +165,23 @@
|
|
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.
|
|
- await b.key('k', 'KeyK', 2); await wait(`${palette}.includes("#/queue/7")`);
|
|
+ // 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 b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
|
+ await closePalette();
|
|
|
|
// Settings: appearance is the one control, mirrored with the command bar and kept in this browser.
|
|
await go('#/settings', 'Settings');
|
|
@@ -176,6 +191,11 @@
|
|
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")'), []);
|
|
@@ -191,10 +211,21 @@
|
|
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'), 'token at <path> for <id>');
|
|
+ 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');
|