77 lines
5.4 KiB
JavaScript
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"/);
|
|
});
|