Files
stack/packages/webui/tests/processing-ui.test.mjs

77 lines
5.4 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
const source = new URL('../src/public/processing.js', import.meta.url);
const GEN = '12345678-1234-4234-8234-123456789abc';
const flush = async () => { for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve)); };
async function fixture({ send = true, halted = false, unknown = false, partial = false, confirm = true } = {}) {
const nodes = new Map(), requests = [], events = new Map(), classes = new Set(); let confirmations = 0;
for (const id of ['processing-controls', 'processing-state', 'processing-halt', 'processing-resume', 'processing-unavailable', 'processing-receipt', 'processing-banner'])
nodes.set(id, { id, hidden: false, disabled: false, textContent: '', dataset: {}, listeners: new Map(), addEventListener(name, fn) { this.listeners.set(name, fn); } });
const state = { version: 1, generation: GEN, halted, quiescent: !partial, remaining: partial ? 1 : 0, stopped: 0 };
runInNewContext(readFileSync(source, 'utf8'), {
document: { getElementById: id => nodes.get(id), body: { classList: { contains: name => classes.has(name) } },
addEventListener: (name, fn) => events.set(name, fn),
dispatchEvent: event => { if (event.type === 'mosaic-signed-out') classes.add('signed-out'); events.get(event.type)?.(event); } },
Event, CustomEvent,
window: { addEventListener: (name, fn) => events.set(name, fn), confirm: () => { confirmations++; return confirm; } },
setInterval: () => ({ unref() {} }), clearInterval() {},
fetch: async (url, options = {}) => {
requests.push({ url, ...options });
if (url === '/api/config') return { ok: true, json: async () => ({ send }) };
if (options.method === 'POST') {
if (unknown) return { ok: false, json: async () => ({ error: 'outcome-unknown', message: 'untrusted backend text' }) };
state.halted = url.endsWith('/halt');
return { ok: true, json: async () => ({ ...state }) };
}
return { ok: true, json: async () => ({ ...state }) };
},
});
events.get('DOMContentLoaded')();
await flush();
const click = async id => { await nodes.get(id).listeners.get('click')(); await flush(); };
return { nodes, requests, events, state, click, confirmations: () => confirmations };
}
test('interim read-only controls show CLI recovery, disable both writes and retain a halted banner across views', async () => {
const f = await fixture({ send: false, halted: true });
assert.equal(f.nodes.get('processing-halt').disabled, true); assert.equal(f.nodes.get('processing-resume').disabled, true);
assert.match(f.nodes.get('processing-unavailable').textContent, /mosaic halt.*mosaic resume/);
assert.equal(f.nodes.get('processing-banner').hidden, false);
await f.events.get('hashchange')(); await flush();
assert.equal(f.nodes.get('processing-banner').hidden, false);
assert.equal(f.requests.filter(r => r.method === 'POST').length, 0);
});
test('Stop sends one empty halt and Resume confirms before sending the displayed generation', async () => {
const f = await fixture(); await f.click('processing-halt');
assert.equal(f.nodes.get('processing-banner').hidden, false);
await f.click('processing-resume'); assert.equal(f.confirmations(), 1);
const writes = f.requests.filter(r => r.method === 'POST');
assert.deepEqual(writes.map(r => [r.url, JSON.parse(r.body)]), [['/api/processing/halt', {}], ['/api/processing/resume', { generation: GEN }]]);
assert.ok(writes.every(r => r.headers['content-type'] === 'application/json'));
});
test('a cancelled resume never calls the writer', async () => {
const f = await fixture({ halted: true, confirm: false }); await f.click('processing-resume');
assert.equal(f.requests.filter(r => r.method === 'POST').length, 0);
});
test('unknown writes are not retried or displayed as success and partial stops remain explicit', async () => {
const f = await fixture({ unknown: true }); await f.click('processing-halt');
assert.equal(f.requests.filter(r => r.method === 'POST').length, 1);
assert.match(f.nodes.get('processing-receipt').textContent, /Outcome unknown/);
assert.doesNotMatch(f.nodes.get('processing-receipt').textContent, /untrusted backend text|Processing halted\./);
const partial = await fixture({ partial: true }); await partial.click('processing-halt');
assert.match(partial.nodes.get('processing-receipt').textContent, /unconfirmed/);
});
test('the persistent shell loads the control outside route-specific content with accessible status and native buttons', () => {
const html = readFileSync(new URL('../src/public/index.html', import.meta.url), 'utf8');
const controlsAt = html.indexOf('id="processing-controls"');
const mainAt = html.indexOf('<main id="main"'), inspectorAt = html.indexOf('<aside class="inspector"');
assert.ok(controlsAt > mainAt && controlsAt < inspectorAt, 'controls are in the Dashboard main content');
assert.ok(!(inspectorAt < controlsAt && controlsAt < html.indexOf('</aside>', inspectorAt)),
'controls are outside aside.inspector');
assert.match(html, /src="\/processing.js"/); assert.match(html, /href="\/processing.css"/);
assert.match(html, /id="processing-receipt"[^>]*aria-live="polite"/);
assert.match(html, /<button[^>]*id="processing-halt"[^>]*type="button"/);
assert.match(html, /<button[^>]*id="processing-resume"[^>]*type="button"/);
});