feat(console): Stop bus stops one organization's bus cleanly (#1873) #1941

Merged
jason.woltje merged 2 commits from bus-stop into refactor 2026-11-11 22:14:14 +00:00
8 changed files with 482 additions and 24 deletions

No files matched your search

+62 -1
View File
@@ -18,6 +18,7 @@
// POST /api/orgs/ID/setup/bus C3 busStart mosaic bus start --detach
// POST /api/orgs/ID/restart/bus C3 busRestart mosaic bus restart (F3; busStart when stopped;
// busRevive for a host that died, dev-local owner only, D2)
// POST /api/orgs/ID/stop/bus C3 busStop mosaic bus stop (a stopped bus answers state stopped)
//
// GET /api/orgs (the list) and other /api/orgs/ID/... routes are S5's; this
// handler returns false for them.
@@ -35,7 +36,7 @@ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
const MAX_BODY = 64 * 1024;
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart|stop)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
// The org-template placeholder (packages/organization/src/org-service.mjs templateRender).
export const placeholderRoot = (id, project) => `/__mosaic_org__/${id}/projects/${project}`;
// Project fields the server owns. The browser never sees or sends them. `agents` (#1873 A1) is
@@ -54,6 +55,7 @@ export function verbFor(step, id, opts = {}) {
if (step === 'records') return `mosaic project init ${id} ${opts.project}`;
if (step === 'bus') return `mosaic bus start ${id} --dev-local-owner --detach`;
if (step === 'bus-restart') return `mosaic bus restart ${id}`;
if (step === 'bus-stop') return `mosaic bus stop ${id}`;
if (step === 'bus-revive') return `mosaic bus recover ${id}; mosaic bus start ${id} --dev-local-owner --detach`;
throw new Error(`unknown step ${step}`);
}
@@ -522,6 +524,7 @@ async function route(req, res, { path, search, org, admin, principal, write, log
try { return json(res, 200, await readSetup(admin, ctx, id)); } catch (e) { return refusal(res, e, { step: 'setup', id }); }
}
if (kind === 'restart') return restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by });
if (kind === 'stop') return stopBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by });
if (kind !== 'setup' || !sub || !write) return json(res, 404, { error: 'not found' });
// Every setup write reads the step back first and never overwrites.
@@ -613,6 +616,64 @@ async function restartBus(req, res, { sub, write, id, ctx, service, admin, log,
return json(res, 200, { outcome: 'done', step, restarted: running, healthy, receipt: forReceipt(ctx, receipt), verb: running ? verbFor('bus-restart', id) : verbFor('bus', id) });
}
// Stop bus: the same owner, origin, --send and trail rules as Restart bus. C3 busStop sends SIGTERM
// to the proven host and waits for it to exit; the host's close() removes host.json and host.lock,
// and the broker's store removes writer.lock, so a clean stop leaves no lock behind (an offline
// migrate-schema run can follow). The bus state is read back after the stop: a host or lock still
// there, or state that does not check out, is a failure, not a done receipt. A host whose state
// cannot be proven refuses before anything changes; there is no recovery path here, unlike Restart
// bus on the dev Console. A bus that is already stopped answers done with state stopped.
async function stopBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }) {
const step = 'bus-stop';
if (sub !== 'bus' || !write) return json(res, 404, { error: 'not found' });
const body = await readBody(req); strictKeys(body, []);
try { await service.showOrg(ctx, id); } catch (e) { return refusal(res, e, { step, id }); }
const bus = await need(admin, 'bus', res, step); if (!bus) return;
if (typeof bus.busStop !== 'function') return json(res, 503, { error: 'not-configured', step, message: 'bus control cannot stop a bus on this Console yet' });
if (!trail) return json(res, 503, { error: 'trail-unavailable', step, message: 'this Console has no action trail, so it does not stop buses' });
// The dev Console's start-up revive may be bringing this bus back; the press acts on the result.
await revivePending(id);
let status;
try { status = await bus.busStatus(ctx, id); }
catch (e) {
if (['org-unknown', 'org-mismatch', 'org-unavailable', 'org-archived'].includes(e?.code)) return refusal(res, e, { step, id });
if (isCliError(e)) {
// Refused before anything changed, but the owner asked: the trail records the attempt.
try { trail({ by, action: 'bus.stop', org: id, outcome: 'refused', running: null }); } catch { log(`org-admin: ${id} bus stop: trail write failed after refused`); }
return json(res, 409, { error: 'bus-refused', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` });
}
return refusal(res, e, { step, id });
}
const running = Number.isSafeInteger(status?.pid);
try { trail({ by, action: 'bus.stop', org: id, outcome: 'requested', running }); }
catch { return json(res, 500, { error: 'trail-unavailable', step, message: 'the action trail could not be written, so nothing was stopped' }); }
const note = outcome => { try { trail({ by, action: 'bus.stop', org: id, outcome }); } catch { log(`org-admin: ${id} bus stop: trail write failed after ${outcome}`); } };
if (!running) {
note('done');
return json(res, 200, { outcome: 'done', step, stopped: false, state: 'stopped', receipt: { org: id, stopped: false }, verb: verbFor('bus-stop', id) });
}
let receipt;
try { receipt = await bus.busStop(ctx, id); }
catch (e) {
// Only exit 3 comes before the signal; a timeout or an unexpected error may follow SIGTERM.
note(isCliError(e) && e.exitCode === 3 ? 'refused' : 'failed');
if (!isCliError(e)) return refusal(res, e, { step, id, unknownOk: true });
return json(res, 409, { error: e.exitCode === 3 ? 'bus-refused' : 'bus-failed', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}.` });
}
// Read the state back: the stop is done only when no host and no lock is left.
let after = null, left = 'a bus host still runs';
try { after = await bus.busStatus(ctx, id); }
catch (e) { if (!isCliError(e)) { note('failed'); return refusal(res, e, { step, id, unknownOk: true }); } left = plainMessage(ctx, e.message); }
if (!after || Number.isSafeInteger(after.pid)) {
note('failed');
return json(res, 409, { error: 'bus-failed', step, message: `the bus host was stopped, but ${left}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` });
}
note('done');
log(`org-admin: ${id} bus stopped`);
// A host that exited on its own between the read and the stop was not stopped by this press.
return json(res, 200, { outcome: 'done', step, stopped: receipt?.stopped === true, state: 'stopped', receipt: forReceipt(ctx, { org: id, ...receipt }), verb: verbFor('bus-stop', id) });
}
// The dev-local owner exactly (packages/cli/src/dev-local-owner.mjs). A signed-in session carries
// its provider's issuer, so no SSO user matches.
const isDevOwner = by => by?.provider === DEV_OWNER.provider && by.iss === DEV_OWNER.iss && by.sub === DEV_OWNER.sub;
+33 -7
View File
@@ -15,7 +15,7 @@
const sleep = ms => new Promise(r => setTimeout(r, ms));
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
let config = null, boot = null, reconnecting = false, planned = false;
let restartState = 'idle', restartReceipt = '', busReceipt = '', busBusy = false;
let restartState = 'idle', restartReceipt = '', busReceipt = '', busBusy = false, busAction = 'restart';
const readConfig = () => nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })
.then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null));
@@ -119,11 +119,17 @@
else if (e.target.id === 'console-restart-go' && restartState === 'confirm') restartConsole();
});
// The org bus restart control, offered on the organization's own pages to its owner.
// The org bus restart control, offered on the organization's own pages to its owner. With
// { stop: true } (the organization header) a Stop bus button sits beside it: one press, like
// Restart bus, sharing its receipt and busy state. glyph is the caller's own icon markup, kept
// inside the restart button so it stays on the button whatever width the receipt takes.
const busAllowed = () => !!window.MosaicOrg && config?.orgSetup === true;
function busButton(label = 'Restart bus') {
function busButton(label = 'Restart bus', { stop = false, glyph = '' } = {}) {
if (!busAllowed()) return '';
return `<span class="bus-restart"><button type="button" class="btn" data-bus-restart data-label="${esc(label)}"${busBusy ? ' disabled' : ''}>${esc(busBusy ? 'Restarting…' : label)}</button> <span role="status" data-bus-restart-receipt>${esc(busReceipt)}</span></span>`;
const off = busBusy ? ' disabled' : '';
const restart = `<button type="button" class="btn" data-bus-restart data-label="${esc(label)}"${stop ? ' data-stop' : ''}${off}>${glyph}${esc(busBusy && busAction === 'restart' ? 'Restarting…' : label)}</button>`;
const buttons = stop ? `<span class="bus-buttons">${restart} <button type="button" class="btn" data-bus-stop${off}>${busBusy && busAction === 'stop' ? 'Stopping…' : 'Stop bus'}</button></span>` : restart;
return `<span class="bus-restart">${buttons} <span role="status" data-bus-restart-receipt>${esc(busReceipt)}</span></span>`;
}
async function attachBus(box, label) {
await ready;
@@ -133,14 +139,16 @@
box.insertAdjacentHTML('beforeend', ` ${busButton(label)}`);
}
const paintBus = () => document.querySelectorAll('.bus-restart').forEach(el => {
const label = el.querySelector('[data-bus-restart]')?.dataset.label;
el.outerHTML = busButton(label);
const button = el.querySelector('[data-bus-restart]');
el.outerHTML = busButton(button?.dataset.label, { stop: button?.hasAttribute('data-stop'), glyph: button?.querySelector('svg')?.outerHTML || '' });
});
document.addEventListener('click', async e => {
const button = e.target.closest?.('[data-bus-restart]');
const button = e.target.closest?.('[data-bus-restart],[data-bus-stop]');
if (!button || button.disabled || busBusy || !busAllowed()) return;
const id = window.MosaicOrg.id;
busAction = button.hasAttribute('data-bus-stop') ? 'stop' : 'restart';
busBusy = true; busReceipt = ''; paintBus();
if (busAction === 'stop') return stopBus(id);
try {
const response = await nativeFetch(`/api/orgs/${encodeURIComponent(id)}/restart/bus`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' });
if (response.type === 'opaqueredirect') throw new Error('Sign in again, then retry.');
@@ -170,5 +178,23 @@
} finally { busBusy = false; paintBus(); }
});
// Stop bus: the bus host stops cleanly and leaves no lock behind. An already stopped bus answers
// done with state stopped. The page reads the list and its send flags again, as after a restart.
async function stopBus(id) {
try {
const response = await nativeFetch(`/api/orgs/${encodeURIComponent(id)}/stop/bus`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' });
if (response.type === 'opaqueredirect') throw new Error('Sign in again, then retry.');
const body = await response.json();
if (!response.ok || body.outcome !== 'done' || body.step !== 'bus-stop' || body.state !== 'stopped') throw new Error(body.message || 'The stop was refused. Nothing was retried.');
busReceipt = body.stopped ? `Bus stopped at ${clock()}. Restart bus starts it again.` : `The bus was already stopped at ${clock()}. Nothing changed.`;
$('bus-restart-done')?.remove();
await Promise.resolve(window.MosaicOrg.refreshList?.()).catch(() => {});
const fresh = await window.fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => (r.ok ? r.json() : null)).catch(() => null);
if (fresh) { config = fresh; document.dispatchEvent(new CustomEvent('mosaic-config', { detail: fresh })); }
} catch (error) {
busReceipt = error instanceof SyntaxError || error?.name === 'TypeError' ? 'Outcome unknown. Check the bus with mosaic bus status before retrying.' : error.message;
} finally { busBusy = false; paintBus(); }
}
window.MosaicConsole = Object.freeze({ ready, settingsMarkup, busButton, attachBus, get busRestart() { return busAllowed(); }, get config() { return config; } });
})();
+9 -8
View File
@@ -431,16 +431,16 @@ html[data-organizations] .ws-org-row .ws-dot{font-size:12.5px;font-weight:500}
html[data-organizations] .ws-org-row .ws-dot.s-offline,html[data-organizations] .ws-org-row .ws-dot.s-error{color:var(--text)}
html[data-organizations] .ws-org-row .ws-dot.s-offline b{color:var(--muted)}
html[data-organizations] .ws-org-row .ws-dot.s-error b{color:var(--danger)}
html[data-organizations] .ws-btn,html[data-organizations] .ws-busctl [data-bus-restart]{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font:600 13px/1.4 var(--font);color:var(--strong);text-decoration:none;white-space:nowrap;cursor:pointer;box-shadow:none}
html[data-organizations] .ws-btn:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-restart]:hover:not(:disabled){background:var(--raised)}
html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [data-bus-restart]:disabled{color:var(--muted);cursor:not-allowed;opacity:1}
html[data-organizations] .ws-btn,html[data-organizations] .ws-busctl [data-bus-restart],html[data-organizations] .ws-busctl [data-bus-stop]{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font:600 13px/1.4 var(--font);color:var(--strong);text-decoration:none;white-space:nowrap;cursor:pointer;box-shadow:none}
html[data-organizations] .ws-btn:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-restart]:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-stop]:hover:not(:disabled){background:var(--raised)}
html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [data-bus-restart]:disabled,html[data-organizations] .ws-busctl [data-bus-stop]:disabled{color:var(--muted);cursor:not-allowed;opacity:1}
html[data-organizations] .ws-btn svg.i{width:15px;height:15px}
/* The live bus restart is console-restart.js's own button; its icon sits beside it, outside the
part that file repaints. */
html[data-organizations] .ws-busctl{position:relative;display:inline-flex;flex-direction:column;align-items:flex-end}
/* The live bus restart is console-restart.js's own button; it carries its icon through repaints,
so the icon stays on the button however wide the receipt below it is. */
html[data-organizations] .ws-busctl{display:inline-flex;flex-direction:column;align-items:flex-end}
html[data-organizations] .ws-busctl .bus-restart{display:inline-flex;flex-direction:column;align-items:flex-end;gap:4px}
html[data-organizations] .ws-busctl [data-bus-restart]{padding-left:31px}
html[data-organizations] .ws-busctl .ws-busicon{position:absolute;left:11px;top:7px;width:15px;height:15px;pointer-events:none;color:var(--strong);z-index:1}
html[data-organizations] .ws-busctl .bus-buttons{display:inline-flex;flex-wrap:wrap;gap:6px;justify-content:flex-end}
html[data-organizations] .ws-busctl [data-bus-restart] svg.i{width:15px;height:15px}
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{font-size:12px;color:var(--muted);max-width:28em;text-align:right}
html[data-organizations] .ws-busctl [data-bus-restart-receipt]:empty{display:none}
html[data-organizations] .ws-chips{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
@@ -689,6 +689,7 @@ html[data-organizations] #copy-status:empty{display:none}
html[data-organizations] .ws-orghead .ws-right{margin-left:0}
html[data-organizations] .ws-busctl,html[data-organizations] .ws-busctl .bus-restart{align-items:flex-start}
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{text-align:left}
html[data-organizations] .ws-busctl .bus-buttons{justify-content:flex-start}
html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:0}
html[data-organizations] .ws-levels{grid-template-columns:minmax(0,1fr)!important}
html[data-organizations] .ws-levels>div{border-right:0}
+3 -2
View File
@@ -219,9 +219,10 @@
// Header: the bus restart is console-restart.js's button where the Console allows it, otherwise
// the same control, present and disabled, with the reason.
const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart bus') || '';
const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart bus', { stop: true, glyph: icon('restart') }) || '';
const offTitle = pane ? "Restart the bus from this organization's full view" : "Restarting the bus is the owner's trailed action, on a Console started with send and the action trail";
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="${esc(offTitle)}">${icon('restart')}Restart bus</button></span>`;
const stopTitle = pane ? "Stop the bus from this organization's full view" : "Stopping the bus is the owner's trailed action, on a Console started with send and the action trail";
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="${esc(offTitle)}">${icon('restart')}Restart bus</button> <button type="button" class="ws-btn" data-bus-stop-off data-focus-key="bus-stop-off" disabled title="${esc(stopTitle)}">Stop bus</button></span>`;
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="${esc(why)}" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
const [bcls, bglyph] = state(org);
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="${esc(why)}">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
+1 -1
View File
@@ -828,7 +828,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
for (const registerRoutes of routes) registerRoutes(registered);
registered.add('GET', '/api/themes', (req, res) => themesHTTP(req, res, { log }), { publicRead: true });
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log, trail });
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore|restart)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore|restart|stop)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
for (const method of ['GET', 'POST']) registered.add(method, path, adminRoute);
}
registered.add('POST', '/api/orgs', adminRoute);
+127
View File
@@ -0,0 +1,127 @@
// Stop bus (#1873, bus-stop): the Console's POST /api/orgs/ID/stop/bus stops one organization's bus
// host the way `mosaic bus stop` does, with SIGTERM and a clean close, so no host.lock or writer.lock
// is left and an offline schema migration can open the store straight after. A real scratch instance
// (bootstrap.sh), a real bus host and a real dev-owner Console on port 0.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { request } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { humanEnv } from '../../cli/tests/helpers.mjs';
import { busStatus, busStop } from '../../cli/src/org-bus.mjs';
import { consoleStart, consoleStop } from '../../cli/src/org-console.mjs';
import { createOrg, templateRender } from '../../organization/src/org-service.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
const MOSAIC = join(REPO, 'scripts/mosaic');
const MIGRATE = join(REPO, 'packages/bus/src/migrate-schema.mjs');
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350];
function mosaic(env, args, timeout = 60000) {
return new Promise((resolve, reject) => {
const child = spawn(MOSAIC, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
child.on('error', reject);
child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); });
});
}
// A request the way the page sends it to its own Console.
function post(port, path) {
return new Promise((resolve, reject) => {
const host = `127.0.0.1:${port}`;
const req = request({ host: '127.0.0.1', port, path, method: 'POST', headers: { host, origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } }, res => {
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
res.on('end', () => { let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ } resolve({ status: res.statusCode, body, text }); });
});
req.on('error', reject); req.end('{}');
});
}
// Every file under the bus state directories, with its content hash.
function snapshot(root) {
const out = {};
for (const dir of ['bus-host', 'bus']) {
const base = join(root, dir);
if (!existsSync(base)) continue;
for (const name of readdirSync(base).sort()) {
const p = join(base, name), st = statSync(p);
out[`${dir}/${name}`] = st.isFile() ? createHash('sha256').update(readFileSync(p)).digest('hex') : st.isDirectory() ? 'dir' : 'other';
}
}
return out;
}
test('Stop bus stops a live bus cleanly: no locks, an offline migration runs, a second press answers stopped, setup starts it again', { timeout: 180000 }, async t => {
const home = mkdtempSync(join(tmpdir(), 'bs-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-bstop/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'bstop'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-bstop'), dataRoot: join(home, '.mosaic-bstop'), instance: 'bstop', env };
t.after(async () => {
try { await busStop(ctx, 'alpha'); } catch {}
try { await consoleStop(ctx, { warn: () => {} }); } catch {}
rmSync(home, { recursive: true, force: true });
});
await createOrg({ ...ctx, principal: DEV }, 'alpha', await templateRender('alpha', { fixture: 'local' }));
const started = await mosaic(env, ['bus', 'start', 'alpha', '--no-launch', '--dev-local-owner', '--detach']);
assert.equal(started.status, 0, started.stderr);
const first = await busStatus(ctx, 'alpha');
assert.equal(first.healthy, true);
const root = first.root;
const hostLock = join(root, 'bus-host', 'host.lock'), writerLock = join(root, 'bus', 'writer.lock');
// A live bus holds both locks, so their absence afterwards is the stop's doing.
assert.ok(existsSync(hostLock), 'a live bus holds host.lock'); assert.ok(existsSync(writerLock), 'a live bus holds writer.lock');
const run = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
const port = Number(new URL(run.url).port);
assert.ok(port > 0 && !FORBIDDEN.includes(port), String(port));
const stop = await post(port, '/api/orgs/alpha/stop/bus');
assert.equal(stop.status, 200, stop.text);
assert.equal(stop.body.step, 'bus-stop'); assert.equal(stop.body.state, 'stopped'); assert.equal(stop.body.stopped, true);
assert.equal(stop.body.receipt.pid, first.pid);
assert.ok(!stop.text.includes(home), 'the receipt names no host path');
assert.ok(!existsSync(`/proc/${first.pid}`) || !readFileSync(`/proc/${first.pid}/cmdline`, 'utf8').includes('--internal-host'), 'the host process is gone');
assert.ok(!existsSync(hostLock), 'no host.lock left'); assert.ok(!existsSync(writerLock), 'no writer.lock left');
const status = await busStatus(ctx, 'alpha');
assert.equal(status.healthy, false); assert.equal(status.pid ?? null, null);
// The offline schema migration opens the store straight after (it refuses writer-locked otherwise).
const migrate = spawnSync(process.execPath, [MIGRATE, root], { env: { PATH: process.env.PATH }, encoding: 'utf8' });
assert.equal(migrate.status, 0, migrate.stderr);
assert.match(migrate.stdout, /bus schema \S+ ready/);
assert.ok(!existsSync(writerLock), 'the migration released the store');
// A second press finds it stopped and changes nothing.
const quiet = snapshot(root);
const again = await post(port, '/api/orgs/alpha/stop/bus');
assert.equal(again.status, 200, again.text);
assert.deepEqual([again.body.step, again.body.state, again.body.stopped], ['bus-stop', 'stopped', false]);
assert.deepEqual(snapshot(root), quiet);
// A host lock this Console cannot prove (a live pid that is not the recorded process) refuses,
// and every bus file stays as it was.
writeFileSync(hostLock, JSON.stringify({ pid: process.pid, startTime: '1', business: 'alpha', startedAt: new Date().toISOString() }));
const unproven = snapshot(root);
const refused = await post(port, '/api/orgs/alpha/stop/bus');
assert.equal(refused.status, 409, refused.text); assert.equal(refused.body.error, 'bus-refused');
assert.match(refused.body.message, /^stale bus host lock\. Inspect it with mosaic bus status alpha/);
assert.deepEqual(snapshot(root), unproven);
rmSync(hostLock);
// The setup step starts it again.
const up = await post(port, '/api/orgs/alpha/setup/bus');
assert.equal(up.status, 201, up.text); assert.equal(up.body.step, 'bus');
const back = await busStatus(ctx, 'alpha');
assert.equal(back.healthy, true); assert.notEqual(back.pid, first.pid);
assert.ok(existsSync(hostLock)); assert.ok(existsSync(writerLock));
// And stops again from the page.
const last = await post(port, '/api/orgs/alpha/stop/bus');
assert.equal(last.status, 200, last.text); assert.equal(last.body.stopped, true);
assert.ok(!existsSync(hostLock)); assert.ok(!existsSync(writerLock));
});
+195
View File
@@ -39,6 +39,7 @@ function fakes() {
busStart: async (ctx, id, opts) => { calls.push(['busStart', id, opts]); running = id; return { org: id, pid: 4242, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
busStopForDelete: async (ctx, id) => { calls.push(['busStopForDelete', id]); running = null; return { stopped: false, reason: 'no host runs' }; },
busRestart: async (ctx, id) => { calls.push(['busRestart', id]); running = id; return { org: id, pid: 4343, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
busStop: async (ctx, id) => { calls.push(['busStop', id]); const was = running === id; running = null; return was ? { stopped: true, business: id, pid: 4242 } : { stopped: false, reason: 'no host runs' }; },
};
const admin = createOrgAdmin({ service, registry, bus, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
return { admin, orgs, calls, fail, service };
@@ -686,3 +687,197 @@ test('D2: a press during the Console start-up revive waits for it and acts on th
assert.equal(r.status, 200, r.raw); assert.equal(r.body.restarted, true);
assert.deepEqual(c.calls.filter(x => x[0] === 'busRestart'), [['busRestart', 'acme']]);
});
// Stop bus: the owner stops one organization's bus from its page. Same owner, origin, --send and
// trail rules as Restart bus; a stopped bus answers state stopped and nothing changes.
const STOP = '/api/orgs/acme/stop/bus';
const STOP_DONE = { outcome: 'done', step: 'bus-stop', stopped: true, state: 'stopped', receipt: { org: 'acme', stopped: true, business: 'acme', pid: 4242 }, verb: 'mosaic bus stop acme' };
test('stop: the owner stops a running bus, a second press answers stopped, and setup starts it again', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
assert.equal((await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') })).status, 201);
assert.equal((await c.call('POST', '/api/orgs/acme/setup/bus', {})).status, 201);
let r = await c.call('POST', STOP, {});
assert.equal(r.status, 200, r.raw);
assert.deepEqual(r.body, STOP_DONE);
r = await c.call('POST', STOP, {});
assert.equal(r.status, 200, r.raw);
assert.deepEqual(r.body, { outcome: 'done', step: 'bus-stop', stopped: false, state: 'stopped', receipt: { org: 'acme', stopped: false }, verb: 'mosaic bus stop acme' });
// The second press changed nothing: busStop ran once.
assert.deepEqual(c.calls.filter(x => x[0] === 'busStop'), [['busStop', 'acme']]);
const up = await c.call('POST', '/api/orgs/acme/setup/bus', {});
assert.equal(up.status, 201, up.raw); assert.equal(up.body.step, 'bus');
assert.equal(c.calls.filter(x => x[0] === 'busStart').length, 2);
assert.deepEqual(tr.lines(), [
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'requested', running: true },
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'done' },
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'requested', running: false },
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'done' },
]);
});
test('stop: another origin or site, no owner or --send, a body, GET or no organization is refused like Restart bus', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
await c.call('POST', '/api/orgs/acme/setup/bus', {});
for (const headers of [{ origin: null }, { origin: 'http://evil.example' }, { origin: `http://localhost:${c.port + 1}` }, { 'sec-fetch-site': null }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': 'same-site' }, { 'x-mosaic-tester': '1' }]) {
const [stop, restart] = [await c.call('POST', STOP, {}, headers), await c.call('POST', '/api/orgs/acme/restart/bus', {}, headers)];
assert.equal(stop.status, 403, JSON.stringify(headers));
assert.deepEqual([stop.status, stop.body?.error], [restart.status, restart.body?.error], JSON.stringify(headers));
}
assert.equal((await c.call('POST', STOP, {}, { 'content-type': 'text/plain' })).status, 415);
assert.equal((await c.call('POST', STOP, { now: true })).status, 400);
assert.equal((await c.call('GET', STOP)).status, 404);
assert.equal((await c.call('POST', '/api/orgs/acme/stop/queue', {})).status, 404);
assert.equal((await c.call('POST', '/api/orgs/acme/stop', {})).status, 404);
const unknown = await c.call('POST', '/api/orgs/ghost/stop/bus', {});
assert.equal(unknown.status, 404); assert.equal(unknown.body.error, 'org-unavailable');
const readOnly = await console_(t, { trail: tr.trail, send: null });
const ro = await readOnly.call('POST', STOP, {});
assert.equal(ro.status, 403); assert.equal(ro.body.error, 'read-only');
const anon = await console_(t, { trail: tr.trail, sso: null, send: null });
const a = await anon.call('POST', STOP, {});
assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only');
assert.equal(c.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0);
assert.deepEqual(tr.lines(), []);
});
test('stop: on a deployed (signed-in) Console a granted user is refused like Restart bus, and the owner stops the bus', async t => {
const { createSsoSession } = await import('./helpers/sso-session.mjs');
const { consoleAccess } = await import('../src/access.mjs');
const tr = trailIn(t);
const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
grants: [{ id: 'g-1', user: 'alice', business: 'acme', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'acme' }) });
const f = fakes();
await f.service.createOrg({}, 'acme', template('acme'));
const bus = await f.admin.bus();
await bus.busStart({}, 'acme', {});
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), orgAdmin: f.admin, log: () => {}, trail: tr.trail });
t.after(() => close(server));
const port = server.address().port;
const post = (path, cookie, headers = {}) => new Promise((resolve, reject) => {
const all = { ...s.writeHeaders(cookie), 'content-length': 2, ...headers };
const req = request({ host: '127.0.0.1', port, method: 'POST', path, headers: all }, res => { let raw = ''; res.on('data', x => raw += x); res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch {} resolve({ status: res.statusCode, body: json, raw }); }); });
req.on('error', reject); req.end('{}');
});
const alice = await s.signIn(port, { sub: 'alice-sub' });
for (const cookie of [null, alice]) {
const [stop, restart] = [await post(STOP, cookie), await post('/api/orgs/acme/restart/bus', cookie)];
assert.ok(stop.status === 401 || stop.status === 403 || stop.status === 404, stop.raw);
assert.deepEqual([stop.status, stop.body?.error], [restart.status, restart.body?.error]);
}
assert.equal(f.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0);
assert.deepEqual(tr.lines(), []);
// The signed-in owner from another origin is refused too; from the Console's own origin the bus stops.
const owner = await s.signIn(port);
const cross = await post(STOP, owner, { origin: 'https://evil.example' });
assert.equal(cross.status, 403);
const ok = await post(STOP, owner);
assert.equal(ok.status, 200, ok.raw); assert.equal(ok.body.state, 'stopped'); assert.equal(ok.body.stopped, true);
assert.deepEqual(tr.lines().map(e => [e.by.provider, e.by.sub, e.action, e.outcome]), [['writer', 'owner-sub', 'bus.stop', 'requested'], ['writer', 'owner-sub', 'bus.stop', 'done']]);
});
test('stop: a signed-in operator who is not the owner is refused before any bus call', async t => {
const { Readable } = await import('node:stream');
const tr = trailIn(t);
const f = fakes();
await f.service.createOrg({}, 'acme', template('acme'));
for (const path of [STOP, '/api/orgs/acme/restart/bus']) {
const req = Object.assign(Readable.from([Buffer.from('{}')]), { method: 'POST', headersDistinct: { origin: ['http://console.example'], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'] } });
const res = { writeHead(status) { this.status = status; }, end(body) { this.body = JSON.parse(body); } };
await orgAdminHTTP(req, res, { path, admin: f.admin, session: { provider: 'fixture', iss: 'https://issuer.example', sub: 'op', rootOwner: false, instanceRole: 'operator' }, owner: false, send: true, origin: 'http://console.example', trail: tr.trail });
assert.equal(res.status, 403, path); assert.equal(res.body.error, 'owner-only', path);
}
assert.equal(f.calls.filter(x => x[0].startsWith('bus')).length, 0);
assert.deepEqual(tr.lines(), []);
});
test('stop: no trail, or a trail that cannot be written, stops nothing', async t => {
const c = await console_(t);
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
await c.call('POST', '/api/orgs/acme/setup/bus', {});
const r = await c.call('POST', STOP, {});
assert.equal(r.status, 503); assert.equal(r.body.error, 'trail-unavailable');
const broken = await console_(t, { trail: () => { throw new Error('disk full'); } });
await broken.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
await broken.call('POST', '/api/orgs/acme/setup/bus', {});
const b = await broken.call('POST', STOP, {});
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
for (const x of [c, broken]) assert.equal(x.calls.filter(y => y[0] === 'busStop').length, 0);
});
test('stop: a host that cannot be proven refuses with nothing changed, on the dev Console too', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
const bus = await c.admin.bus();
bus.busRevive = async () => assert.fail('Stop bus reached busRevive');
for (const reason of ['stale bus host lock', 'bus host identity mismatch', 'bus host lock does not match host state', 'stale bus writer lock']) {
bus.busStatus = async () => { throw cliError(reason); };
const r = await c.call('POST', STOP, {});
assert.equal(r.status, 409, reason); assert.equal(r.body.error, 'bus-refused');
assert.equal(r.body.message, `${reason}. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.`);
}
assert.equal(c.calls.filter(x => x[0] === 'busStop').length, 0);
assert.deepEqual(tr.lines(), Array(4).fill({ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'refused', running: null }));
});
test('stop: a stop that is refused, times out or leaves state behind is a visible failure', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
await c.call('POST', '/api/orgs/acme/setup/bus', {});
const bus = await c.admin.bus();
const live = async () => ({ org: 'acme', healthy: true, pid: 4242, root: '/data/org-runtime/acme' });
bus.busStatus = live;
bus.busStop = async () => { throw cliError('pid 4242 is not a bus host; refusing to signal it'); };
let r = await c.call('POST', STOP, {});
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-refused');
assert.equal(r.body.message, 'pid 4242 is not a bus host; refusing to signal it. Inspect it with mosaic bus status acme.');
bus.busStop = async () => { throw Object.assign(new Error('bus host pid 4242 did not stop within 60 s'), { name: 'CliError', exitCode: 1 }); };
r = await c.call('POST', STOP, {});
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
// An unexpected error may come after the signal: the outcome is unknown and the trail says failed.
bus.busStop = async () => { throw new Error('EPERM'); };
r = await c.call('POST', STOP, {});
assert.equal(r.status, 502, r.raw); assert.equal(r.body.error, 'outcome-unknown');
// The host was signalled and exited, but a lock is left, or the host is still recorded.
let after;
bus.busStop = async () => { bus.busStatus = after; return { stopped: true, business: 'acme', pid: 4242 }; };
after = async () => { throw cliError('stale bus writer lock'); };
r = await c.call('POST', STOP, {});
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
assert.equal(r.body.message, 'the bus host was stopped, but stale bus writer lock. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.');
bus.busStatus = live; after = live;
r = await c.call('POST', STOP, {});
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
assert.match(r.body.message, /^the bus host was stopped, but a bus host still runs\./);
assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused', 'requested', 'failed', 'requested', 'failed', 'requested', 'failed', 'requested', 'failed']);
// The host exited on its own between the read and the stop: done, but this press stopped nothing.
bus.busStatus = live;
bus.busStop = async () => { bus.busStatus = async () => ({ org: 'acme', healthy: false, pid: null }); return { stopped: false, reason: 'no host runs' }; };
r = await c.call('POST', STOP, {});
assert.equal(r.status, 200, r.raw);
assert.deepEqual([r.body.outcome, r.body.state, r.body.stopped, r.body.receipt.stopped], ['done', 'stopped', false, false]);
});
test('stop: an archived organization, or a Console whose bus control has no stop, refuses', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
const bus = await c.admin.bus();
const busStop = bus.busStop; delete bus.busStop;
const n = await c.call('POST', STOP, {});
assert.equal(n.status, 503); assert.equal(n.body.error, 'not-configured');
bus.busStop = busStop;
bus.busStatus = async () => { throw new OrgError('org-archived', 'organization acme is archived'); };
const a = await c.call('POST', STOP, {});
const ra = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.deepEqual([a.status, a.body.error], [422, 'org-archived'], a.raw);
assert.deepEqual([ra.status, ra.body.error], [422, 'org-archived'], ra.raw);
assert.equal(c.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0);
assert.deepEqual(tr.lines(), []);
});
@@ -3,6 +3,10 @@ import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { createOrgAdmin } from '../src/org-admin.mjs';
import { actionTrail } from '../src/action-trail.mjs';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { fileURLToPath } from 'node:url';
@@ -202,7 +206,7 @@ test('organization dashboard presents sections and honest unavailable controls',
assert.equal(await b.evaluate('document.querySelector("[data-org-reload]").disabled'),true);
});
test('each header control is present, reload is disabled, and restart is refused without the owner', async t => {
test('each header control is present, reload is disabled, and restart and stop are refused without the owner', async t => {
const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(owner));
const readOnly = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(readOnly));
const signedOut = await startServer({ port: 0, organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(signedOut));
@@ -210,27 +214,69 @@ test('each header control is present, reload is disabled, and restart is refused
const wait = waitFor(b);
const header = server => b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`).then(() => wait('document.querySelector("[data-org-dashboard] [data-org-settings]")')).then(() => b.evaluate(`(() => {
const h = document.querySelector('[data-org-dashboard] .ws-orghead'), live = h.querySelector('[data-bus-restart]'), off = h.querySelector('[data-bus-restart-off]'), reload = h.querySelector('[data-org-reload]');
const stop = h.querySelector('[data-bus-stop]'), stopOff = h.querySelector('[data-bus-stop-off]');
return { live: live ? [live.textContent, live.disabled] : null, off: off ? [off.textContent, off.disabled, !!off.title] : null,
stop: stop ? [stop.textContent, stop.disabled, stop.closest('.bus-restart') === live.closest('.bus-restart')] : null, stopOff: stopOff ? [stopOff.textContent, stopOff.disabled, !!stopOff.title] : null,
reload: [reload.textContent, reload.disabled, reload.title, document.getElementById(reload.getAttribute('aria-describedby'))?.textContent] };
})()`));
const RELOAD = ['Reload organization file', true, 'Reload arrives with the cross-org update', 'Restart is a trailed human action; reload arrives with the cross-org update.'];
// The owner on a Console started with --send gets console-restart.js's restart button.
assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, reload: RELOAD });
// Stop bus sits beside it, in the same control, and is one press like Restart bus.
assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, stop: ['Stop bus', false, true], stopOff: null, reload: RELOAD });
// Without --send, or with nobody signed in, the control is present and disabled, and the route refuses.
const post = 'fetch("/api/orgs/ddigital/restart/bus", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async r => [r.status, (await r.json()).error])';
assert.deepEqual(await header(readOnly), { live: null, off: ['Restart bus', true, true], reload: RELOAD });
const stopPost = post.replace('/restart/bus', '/stop/bus');
assert.deepEqual(await header(readOnly), { live: null, off: ['Restart bus', true, true], stop: null, stopOff: ['Stop bus', true, true], reload: RELOAD });
assert.deepEqual(await b.evaluate(post), [403, 'read-only']);
assert.deepEqual(await b.evaluate(stopPost), [403, 'read-only']);
// With nobody signed in the organization list is not readable, so the dashboard reads nothing and
// shows no controls at all.
await b.navigate(`http://127.0.0.1:${signedOut.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelector("[data-org-unavailable]")');
assert.equal(await b.evaluate('document.querySelector("[data-bus-restart], [data-bus-restart-off], [data-org-reload]")'), null);
assert.equal(await b.evaluate('document.querySelector("[data-bus-restart], [data-bus-restart-off], [data-bus-stop], [data-bus-stop-off], [data-org-reload]")'), null);
// With nobody signed in, the server refuses every per-org route before the admin hooks run.
assert.deepEqual(await b.evaluate(post), [404, 'org-unavailable']);
assert.deepEqual(await b.evaluate(stopPost), [404, 'org-unavailable']);
// There is no reload route.
assert.equal(await b.evaluate('fetch("/api/orgs/ddigital/reload", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(r => r.ok)'), false);
});
test('the owner presses Stop bus once: the bus stops, a second press says it was already stopped, and the restart icon stays on its button', async t => {
// A fake bus behind the real route: busStop is the only thing a press reaches.
let running = true; const stops = [];
const bus = {
busStatus: async (ctx, id) => ({ org: id, healthy: running, pid: running ? 4242 : null }),
busStop: async (ctx, id) => { stops.push(id); const was = running; running = false; return was ? { stopped: true, business: id, pid: 4242 } : { stopped: false, reason: 'no host runs' }; },
};
const orgAdmin = createOrgAdmin({ service: { showOrg: async (ctx, id) => ({ id, revision: 1, doc: {} }) }, registry: { listOrgs: async () => [] }, bus, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
const root = mkdtempSync(join(tmpdir(), 'bus-stop-trail-')); t.after(() => rmSync(root, { recursive: true, force: true }));
const trail = actionTrail(join(root, 'console'));
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs({ send: true }), orgAdmin, trail, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelector("[data-org-dashboard] [data-bus-stop]")');
// The icon is part of the restart button, so it moves with it whatever width the receipt takes.
const icon = () => b.evaluate(`(() => { const r = document.querySelector('[data-org-dashboard] [data-bus-restart]'), i = r.querySelector('svg'); if (!i) return null; const a = r.getBoundingClientRect(), c = i.getBoundingClientRect(); return c.left >= a.left && c.right <= a.right && c.top >= a.top && c.bottom <= a.bottom; })()`);
assert.equal(await icon(), true);
await b.evaluate('document.querySelector("[data-org-dashboard] [data-bus-stop]").click()');
await wait('/^Bus stopped at \\d/.test(document.querySelector("[data-org-dashboard] [data-bus-restart-receipt]")?.textContent || "")');
assert.deepEqual(stops, ['ddigital']);
assert.equal(await icon(), true, 'the repaint keeps the icon on the restart button');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-org-dashboard] .bus-buttons button")].map(x => [x.textContent, x.disabled])'), [['Restart bus', false], ['Stop bus', false]]);
await b.evaluate('document.querySelector("[data-org-dashboard] [data-bus-stop]").click()');
await wait('/^The bus was already stopped at \\d/.test(document.querySelector("[data-org-dashboard] [data-bus-restart-receipt]")?.textContent || "")');
// The second press reached the route, which found no host and stopped nothing.
assert.deepEqual(stops, ['ddigital']);
assert.equal(await icon(), true);
// Each press is the owner's trailed action.
const lines = readFileSync(join(root, 'console', 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, by, ...e } = JSON.parse(l); return e; });
assert.deepEqual(lines, [
{ action: 'bus.stop', org: 'ddigital', outcome: 'requested', running: true }, { action: 'bus.stop', org: 'ddigital', outcome: 'done' },
{ action: 'bus.stop', org: 'ddigital', outcome: 'requested', running: false }, { action: 'bus.stop', org: 'ddigital', outcome: 'done' },
]);
});
// Tab from the first sidebar link through the dashboard and return each stop's region and box.
async function tabStops(b, start) {
await b.evaluate(`(${start}).focus()`);
@@ -268,8 +314,9 @@ test('keyboard focus follows visual order and survives a data refresh', async t
assertVisualOrder(wide.filter(s => s.region !== 'other'), regions, '1440');
const keys = wide.map(s => s.key);
for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`);
// The disabled reload is not a tab stop; the restart button is.
// The disabled reload is not a tab stop; the restart button is, and Stop bus follows it.
assert.ok(!keys.includes('org-reload')); assert.ok(keys.includes('Restart bus'));
assert.equal(keys.indexOf('Stop bus'), keys.indexOf('Restart bus') + 1);
await b.viewport(390, 844);
await wait('document.querySelector("[data-org-dashboard]")');
const narrow = (await tabStops(b, 'document.querySelector("[data-org-dashboard] .ws-crumb a")')).filter(s => s.region !== 'other');