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('
mainAt && controlsAt < inspectorAt, 'controls are in the Dashboard main content'); assert.ok(!(inspectorAt < controlsAt && controlsAt < html.indexOf('', 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, /]*id="processing-halt"[^>]*type="button"/); assert.match(html, /]*id="processing-resume"[^>]*type="button"/); });