Add an opt-in fleet mode to the dashboard so an operator can drive the coordinator fleet from the same TUI used for the local folder queue. - lib/fleet-dash.mjs: dependency-injectable read/act adapter over the platform-service /fleet REST surface (jobs, metrics, factories, events, ship/requeue/reject). Pure-ish + fully unit-testable without a live service. - dashboard.mjs: render + act in fleet mode when AQ_FLEET_DASH=1 — board with counts, factories (per-factory rows or metrics aggregate), alerts, running (by lease/factory), actionable JOBS with manifest tags, recent, and a per-job events log. Single-flight async refresh keeps the last good board on failure; ship re-GETs a fresh leaseEpoch before PATCH; run/stop/promote are disabled (no safe server contract). Local mode is byte-for-byte unchanged. - lib/fleet-dash.test.mjs: 22 node:assert assertions (config, stage mapping, toBoard, fetch headers/timeout/errors, board assembly + graceful degradation, events, job actions) wired into selftest.sh. - docs: tick the Phase 3 "TUI re-pointed at /fleet" roadmap boxes. Verified: selftest.sh green (incl. new fleet-dash checks); live non-TTY render smoke against a stub /fleet server (both factories and metrics-aggregate paths); local mode unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
288 lines
12 KiB
JavaScript
288 lines
12 KiB
JavaScript
// fleet-dash.test.mjs — dependency-light unit tests for the fleet-mode dashboard
|
|
// adapter. Uses node:assert only (no test framework), matching the repo style.
|
|
// Run: `node fleet-dash.test.mjs` (also wired into selftest.sh).
|
|
//
|
|
// These tests prove the dashboard's CONTRACT ASSUMPTIONS against the /fleet API
|
|
// (request shaping, response mapping, graceful degradation, action semantics)
|
|
// via an injected fetch stub. They do NOT prove live server compatibility.
|
|
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
fleetConfig,
|
|
mapStage,
|
|
toBoard,
|
|
fleetFetch,
|
|
fetchBoard,
|
|
fetchEvents,
|
|
formatEvent,
|
|
jobAction,
|
|
} from './fleet-dash.mjs';
|
|
|
|
let passed = 0;
|
|
const t = (name, fn) => {
|
|
try {
|
|
const r = fn();
|
|
if (r && typeof r.then === 'function') {
|
|
return r.then(
|
|
() => { passed += 1; },
|
|
(e) => { console.error(` ✗ ${name}\n ${e && e.message}`); process.exitCode = 1; },
|
|
);
|
|
}
|
|
passed += 1;
|
|
} catch (e) {
|
|
console.error(` ✗ ${name}\n ${e && e.message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
// A recording fetch stub. `routes` maps a matcher → {status, body} (or a fn).
|
|
function makeFetch(routes) {
|
|
const calls = [];
|
|
const fetchImpl = async (url, opts = {}) => {
|
|
calls.push({ url, opts, headers: opts.headers || {}, method: opts.method || 'GET' });
|
|
let entry = routes[url];
|
|
if (!entry) {
|
|
// try suffix match (path only)
|
|
const key = Object.keys(routes).find((k) => url.endsWith(k));
|
|
entry = key ? routes[key] : undefined;
|
|
}
|
|
if (typeof entry === 'function') entry = entry({ url, opts });
|
|
if (entry === undefined) return mkRes(404, '{}');
|
|
if (entry.throw) throw Object.assign(new Error(entry.throw), { name: entry.name || 'Error' });
|
|
return mkRes(entry.status ?? 200, entry.body ?? '');
|
|
};
|
|
fetchImpl.calls = calls;
|
|
return fetchImpl;
|
|
}
|
|
const mkRes = (status, body) => ({
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
});
|
|
|
|
const CFG = { enabled: true, ok: true, api: 'http://svc/api', token: 'tok', productId: 'prodX', missing: [] };
|
|
|
|
await (async () => {
|
|
// ── fleetConfig ──
|
|
t('fleetConfig: AQ_FLEET_DASH unset ⇒ disabled', () => {
|
|
const c = fleetConfig({});
|
|
assert.equal(c.enabled, false);
|
|
assert.equal(c.ok, false);
|
|
});
|
|
t('fleetConfig: enabled but missing config ⇒ not ok, lists missing', () => {
|
|
const c = fleetConfig({ AQ_FLEET_DASH: '1' });
|
|
assert.equal(c.enabled, true);
|
|
assert.equal(c.ok, false);
|
|
assert.deepEqual(c.missing.sort(), ['AQ_FLEET_API', 'AQ_FLEET_TOKEN', 'AQ_PRODUCT_ID'].sort());
|
|
});
|
|
t('fleetConfig: enabled + complete ⇒ ok, trims trailing slash', () => {
|
|
const c = fleetConfig({ AQ_FLEET_DASH: '1', AQ_FLEET_API: 'http://svc/api/', AQ_FLEET_TOKEN: 'k', AQ_PRODUCT_ID: 'p' });
|
|
assert.equal(c.ok, true);
|
|
assert.equal(c.api, 'http://svc/api');
|
|
});
|
|
|
|
// ── mapStage ──
|
|
t('mapStage: fleet stages collapse to board buckets', () => {
|
|
assert.equal(mapStage('queued'), 'inbox');
|
|
assert.equal(mapStage('assigned'), 'building');
|
|
assert.equal(mapStage('building'), 'building');
|
|
assert.equal(mapStage('review'), 'review');
|
|
assert.equal(mapStage('testing'), 'testing');
|
|
assert.equal(mapStage('shipped'), 'shipped');
|
|
assert.equal(mapStage('failed'), 'failed');
|
|
assert.equal(mapStage('dead_letter'), 'failed');
|
|
assert.equal(mapStage('weird'), 'inbox');
|
|
});
|
|
|
|
// ── toBoard ──
|
|
t('toBoard: counts, actionable items, running, recent', () => {
|
|
const jobs = [
|
|
{ id: 'a', stage: 'queued', priority: 'high', capabilities: ['os:any'] },
|
|
{ id: 'b', stage: 'building', priority: 'critical', factoryId: 'mac-1', leaseEpoch: 3 },
|
|
{ id: 'c', stage: 'review', updatedAt: '2026-01-01T00:00:02Z' },
|
|
{ id: 'd', stage: 'testing' },
|
|
{ id: 'e', stage: 'shipped', updatedAt: '2026-01-01T00:00:09Z' },
|
|
{ id: 'f', stage: 'failed', updatedAt: '2026-01-01T00:00:05Z' },
|
|
{ id: 'g', stage: 'dead_letter', updatedAt: '2026-01-01T00:00:01Z' },
|
|
];
|
|
const b = toBoard({ jobs });
|
|
assert.equal(b.counts.inbox, 1);
|
|
assert.equal(b.counts.building, 1);
|
|
assert.equal(b.counts.review, 1);
|
|
assert.equal(b.counts.testing, 1);
|
|
assert.equal(b.counts.shipped, 1);
|
|
assert.equal(b.counts.failed, 2); // failed + dead_letter
|
|
// running = assigned/building only
|
|
assert.deepEqual(b.running.map((x) => x.id), ['b']);
|
|
assert.equal(b.running[0].fleetStage, 'building');
|
|
assert.equal(b.running[0].factoryId, 'mac-1');
|
|
// actionable items exclude building/shipped, ordered review<testing<failed<inbox
|
|
assert.deepEqual(b.items.map((x) => x.id), ['c', 'd', 'f', 'g', 'a']);
|
|
// item.stage is the bucket (so dashboard gate()/STAGE_TAG reuse works)
|
|
assert.equal(b.items[0].stage, 'review');
|
|
assert.equal(b.items[4].stage, 'inbox');
|
|
// recent = shipped+failed, newest first, capped at 5
|
|
assert.deepEqual(b.recent.map((x) => x.id), ['e', 'f', 'g']);
|
|
});
|
|
|
|
// ── fleetFetch: headers + product scoping on every request ──
|
|
await t('fleetFetch: sends bearer + X-Product-Id; parses JSON', async () => {
|
|
const f = makeFetch({ '/fleet/jobs': { status: 200, body: { jobs: [] } } });
|
|
const r = await fleetFetch(CFG, '/fleet/jobs', {}, f);
|
|
assert.equal(r.ok, true);
|
|
assert.deepEqual(r.json, { jobs: [] });
|
|
const h = f.calls[0].headers;
|
|
assert.equal(h.Authorization, 'Bearer tok');
|
|
assert.equal(h['X-Product-Id'], 'prodX');
|
|
assert.equal(f.calls[0].url, 'http://svc/api/fleet/jobs');
|
|
});
|
|
await t('fleetFetch: network error ⇒ ok:false with message (no throw)', async () => {
|
|
const f = makeFetch({ '/fleet/jobs': { throw: 'boom' } });
|
|
const r = await fleetFetch(CFG, '/fleet/jobs', {}, f);
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.status, 0);
|
|
assert.match(r.error, /boom/);
|
|
});
|
|
await t('fleetFetch: abort ⇒ timeout error', async () => {
|
|
const f = makeFetch({ '/fleet/jobs': { throw: 'aborted', name: 'AbortError' } });
|
|
const r = await fleetFetch(CFG, '/fleet/jobs', {}, f);
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.error, 'timeout');
|
|
});
|
|
await t('fleetFetch: non-JSON 500 body ⇒ ok:false, json null', async () => {
|
|
const f = makeFetch({ '/fleet/jobs': { status: 500, body: '<html>err</html>' } });
|
|
const r = await fleetFetch(CFG, '/fleet/jobs', {}, f);
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.status, 500);
|
|
assert.equal(r.json, null);
|
|
});
|
|
|
|
// ── fetchBoard: assembly + degradation ──
|
|
await t('fetchBoard: jobs + metrics + factories assembled', async () => {
|
|
const f = makeFetch({
|
|
'/fleet/jobs': { body: { jobs: [{ id: 'a', stage: 'review' }] } },
|
|
'/fleet/metrics': { body: { utilizationPct: 50, alerts: [] } },
|
|
'/fleet/factories': { body: { factories: [{ factoryId: 'mac-1', health: 'ok' }] } },
|
|
});
|
|
const r = await fetchBoard(CFG, f);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.board.items.length, 1);
|
|
assert.equal(r.board.metrics.utilizationPct, 50);
|
|
assert.equal(r.board.factories.length, 1);
|
|
});
|
|
await t('fetchBoard: factories 404 ⇒ degrades to []', async () => {
|
|
const f = makeFetch({
|
|
'/fleet/jobs': { body: { jobs: [] } },
|
|
'/fleet/metrics': { body: {} },
|
|
'/fleet/factories': { status: 404, body: {} },
|
|
});
|
|
const r = await fetchBoard(CFG, f);
|
|
assert.equal(r.ok, true);
|
|
assert.deepEqual(r.board.factories, []);
|
|
});
|
|
await t('fetchBoard: factories 501 ⇒ degrades to []', async () => {
|
|
const f = makeFetch({
|
|
'/fleet/jobs': { body: { jobs: [] } },
|
|
'/fleet/metrics': { body: {} },
|
|
'/fleet/factories': { status: 501, body: {} },
|
|
});
|
|
const r = await fetchBoard(CFG, f);
|
|
assert.equal(r.ok, true);
|
|
assert.deepEqual(r.board.factories, []);
|
|
});
|
|
await t('fetchBoard: metrics failure ⇒ board still ok, metrics null', async () => {
|
|
const f = makeFetch({
|
|
'/fleet/jobs': { body: { jobs: [] } },
|
|
'/fleet/metrics': { status: 500, body: 'oops' },
|
|
'/fleet/factories': { body: { factories: [] } },
|
|
});
|
|
const r = await fetchBoard(CFG, f);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.board.metrics, null);
|
|
});
|
|
await t('fetchBoard: jobs failure ⇒ board fails with error', async () => {
|
|
const f = makeFetch({ '/fleet/jobs': { status: 503, body: '{}' } });
|
|
const r = await fetchBoard(CFG, f);
|
|
assert.equal(r.ok, false);
|
|
assert.match(r.error, /503/);
|
|
});
|
|
|
|
// ── events ──
|
|
t('formatEvent: renders type + actor + data', () => {
|
|
const line = formatEvent({ type: 'claimed', actor: 'mac-1', at: '2026-01-01T00:00:00Z', data: { leaseEpoch: 2 } });
|
|
assert.match(line, /claimed/);
|
|
assert.match(line, /mac-1/);
|
|
assert.match(line, /leaseEpoch/);
|
|
});
|
|
await t('fetchEvents: maps events to lines', async () => {
|
|
const f = makeFetch({
|
|
'/events': { body: { events: [{ type: 'queued', at: '2026-01-01T00:00:00Z', data: {} }, { type: 'claimed', data: {} }] } },
|
|
});
|
|
const r = await fetchEvents(CFG, 'job-1', f);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.lines.length, 2);
|
|
assert.match(r.lines[1], /claimed/);
|
|
assert.match(f.calls[0].url, /\/fleet\/jobs\/job-1\/events$/);
|
|
});
|
|
await t('fetchEvents: failure ⇒ ok:false, empty lines', async () => {
|
|
const f = makeFetch({ '/events': { status: 500, body: 'x' } });
|
|
const r = await fetchEvents(CFG, 'job-1', f);
|
|
assert.equal(r.ok, false);
|
|
assert.deepEqual(r.lines, []);
|
|
});
|
|
|
|
// ── jobAction ──
|
|
await t('jobAction: ship re-GETs fresh leaseEpoch then PATCHes shipped', async () => {
|
|
let patchBody = null;
|
|
const f = makeFetch({
|
|
'http://svc/api/fleet/jobs/j1': ({ opts }) => {
|
|
if ((opts.method || 'GET') === 'PATCH') { patchBody = JSON.parse(opts.body); return { status: 200, body: { id: 'j1', stage: 'shipped' } }; }
|
|
return { status: 200, body: { id: 'j1', stage: 'testing', leaseEpoch: 7 } }; // fresh epoch
|
|
},
|
|
});
|
|
const r = await jobAction(CFG, { id: 'j1', leaseEpoch: 2 /* stale */ }, 'ship', f);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(patchBody.stage, 'shipped');
|
|
assert.equal(patchBody.leaseEpoch, 7); // used the freshly-fetched epoch, not the stale 2
|
|
});
|
|
await t('jobAction: ship 409 ⇒ actionable fenced message', async () => {
|
|
const f = makeFetch({
|
|
'http://svc/api/fleet/jobs/j1': ({ opts }) => (opts.method === 'PATCH'
|
|
? { status: 409, body: '{}' }
|
|
: { status: 200, body: { id: 'j1', leaseEpoch: 7 } }),
|
|
});
|
|
const r = await jobAction(CFG, { id: 'j1' }, 'ship', f);
|
|
assert.equal(r.ok, false);
|
|
assert.match(r.message, /fenced|refresh/i);
|
|
});
|
|
await t('jobAction: requeue ⇒ POST /actions/requeue', async () => {
|
|
const f = makeFetch({ '/fleet/jobs/j1/actions/requeue': { status: 200, body: { id: 'j1', stage: 'queued' } } });
|
|
const r = await jobAction(CFG, { id: 'j1' }, 'requeue', f);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(f.calls[0].method, 'POST');
|
|
assert.match(f.calls[0].url, /\/actions\/requeue$/);
|
|
});
|
|
await t('jobAction: reject 409 ⇒ conflict message', async () => {
|
|
const f = makeFetch({ '/fleet/jobs/j1/actions/reject': { status: 409, body: '{}' } });
|
|
const r = await jobAction(CFG, { id: 'j1' }, 'reject', f);
|
|
assert.equal(r.ok, false);
|
|
assert.match(r.message, /conflict|terminal|refresh/i);
|
|
});
|
|
t('jobAction: promote ⇒ explicitly unavailable in fleet mode', async () => {
|
|
return jobAction(CFG, { id: 'j1' }, 'promote', makeFetch({})).then((r) => {
|
|
assert.equal(r.ok, false);
|
|
assert.match(r.message, /promote/i);
|
|
});
|
|
});
|
|
})();
|
|
|
|
// Summary line (selftest greps for PASS).
|
|
process.on('exit', () => {
|
|
if (process.exitCode && process.exitCode !== 0) {
|
|
console.error('fleet-dash.test FAIL');
|
|
} else {
|
|
console.log(`fleet-dash.test PASS (${passed} assertions)`);
|
|
}
|
|
});
|