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]>
267 lines
21 KiB
JavaScript
267 lines
21 KiB
JavaScript
// 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`);
|
|
}
|
|
});
|