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

115 lines
8.2 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { fixture, close } from './fixture.mjs';
import { browser } from './browser.mjs';
const GEN = '12345678-1234-4234-8234-123456789abc';
async function setup(t, readonly = false) {
const f = await fixture(); t.after(() => f.close());
const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
const b = await browser(); t.after(() => b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
const real=fetch.bind(window);window.writes=[];window.failWrite=false;window.confirmResume=true;window.confirmed=0;
window.processingRequests=[];window.processingSignin=null;
const interval=window.setInterval.bind(window),clear=window.clearInterval.bind(window),active=new Set();
window.processingTimers=()=>[...active];
window.setInterval=(fn,ms,...args)=>{const id=interval(fn,ms,...args);if(ms===5000&&new Error().stack.includes('/processing.js:'))active.add(id);return id};
window.clearInterval=id=>{active.delete(id);return clear(id)};
window.confirm=()=>{window.confirmed++;return window.confirmResume};
window.processing={version:1,generation:'${GEN}',halted:${readonly},quiescent:true,remaining:0,stopped:0};
window.fetch=async(url,init={})=>{
if(url==='/api/config'||String(url).startsWith('/api/processing')){
window.processingRequests.push({path:url,method:init.method||'GET',redirect:init.redirect??null});
if(window.processingSignin?.path===url)return window.processingSignin.kind==='redirect'
? {type:'opaqueredirect',ok:false,status:0,json:async()=>{throw Error('redirect has no body')}}
: new Response(JSON.stringify({error:'sign-in required'}),{status:401});
}
if(url==='/api/config')return new Response(JSON.stringify({send:${!readonly}}));
if(!String(url).startsWith('/api/processing'))return real(url,init);
if(init.method==='POST'){
window.writes.push({path:url,body:JSON.parse(init.body),redirect:init.redirect});
if(window.failWrite)return new Response(JSON.stringify({error:'outcome-unknown',message:'do not echo'}),{status:503});
window.processing.halted=String(url).endsWith('/halt');
}
return new Response(JSON.stringify(window.processing));
};
})();` });
await b.navigate(`http://127.0.0.1:${server.address().port}/`);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30));}throw Error(${JSON.stringify(expression)})})()`);
await wait(`document.querySelector('#processing-state')?.textContent.includes(${JSON.stringify(readonly ? 'halted' : 'enabled')})`);
return { b, wait };
}
test('unchanged processing reads retain the status text node', async t => {
const { b, wait } = await setup(t);
await b.evaluate(`window.processingText=document.querySelector('#processing-state').firstChild;window.readsBefore=window.processingRequests.length;window.dispatchEvent(new Event('focus'))`);
await wait('window.processingRequests.length >= window.readsBefore + 2');
await b.evaluate(`new Promise(resolve=>setTimeout(resolve,50))`);
assert.equal(await b.evaluate(`processingText.isConnected && document.querySelector('#processing-state').firstChild===processingText`), true);
});
test('processing browser: keyboard Stop, confirmed Resume, unknown outcome and persistent banner', async t => {
const { b, wait } = await setup(t);
await b.evaluate(`document.querySelector('#processing-halt').focus(),true`); await b.key('Enter');
await wait('window.writes.length===1 && !document.querySelector("#processing-banner").hidden');
assert.deepEqual(await b.evaluate('window.writes[0]'), { path: '/api/processing/halt', body: {}, redirect: 'manual' });
for (const route of ['#/tasks', '#/settings', '#/board']) {
await b.evaluate(`location.hash=${JSON.stringify(route)},true`);
assert.equal(await b.evaluate('document.querySelector("#processing-banner").hidden'), false);
}
await b.evaluate('window.confirmResume=false;document.querySelector("#processing-resume").click();true');
assert.equal(await b.evaluate('window.writes.length'), 1);
await b.evaluate('window.confirmResume=true;document.querySelector("#processing-resume").click();true');
await wait('window.writes.length===2 && document.querySelector("#processing-banner").hidden');
assert.deepEqual(await b.evaluate('window.writes[1].body'), { generation: GEN });
assert.equal(await b.evaluate('window.confirmed'), 2);
await b.evaluate('window.failWrite=true;document.querySelector("#processing-halt").click();true');
await wait('document.querySelector("#processing-receipt").textContent.includes("Outcome unknown")');
assert.equal(await b.evaluate('window.writes.length'), 3);
assert.doesNotMatch(await b.evaluate('document.querySelector("#processing-receipt").textContent'), /do not echo/);
});
test('processing browser: Q175 read-only interim keeps both writes disabled and names CLI recovery at 320px', async t => {
const { b, wait } = await setup(t, true); await b.viewport(320);
assert.equal(await b.evaluate('document.querySelector("#processing-halt").disabled'), true);
assert.equal(await b.evaluate('document.querySelector("#processing-resume").disabled'), true);
assert.match(await b.evaluate('document.querySelector("#processing-unavailable").textContent'), /mosaic halt.*mosaic resume/);
await b.evaluate('document.querySelector("#processing-halt").click();document.querySelector("#processing-resume").click();true');
assert.equal(await b.evaluate('window.writes.length'), 0);
assert.equal(await b.evaluate('document.querySelector("#processing-banner").hidden'), false);
});
for (const [name,path,kind] of [
['config redirect','/api/config','redirect'],
['state redirect','/api/processing','redirect'],
['write redirect','/api/processing/halt','redirect'],
['config sign-in required','/api/config','signin'],
['state sign-in required','/api/processing','signin'],
['write sign-in required','/api/processing/halt','signin'],
]) test(`processing browser: sign-out from ${name} blocks later refresh and writes`, async t => {
const { b, wait } = await setup(t);
assert.equal(await b.evaluate('window.processingRequests.every(r=>r.redirect==="manual")'), true);
assert.equal(await b.evaluate('window.processingTimers().length'), 1);
await b.evaluate(`window.processingSignin=${JSON.stringify({path,kind})};true`);
if(path.endsWith('/halt')) await b.evaluate('document.querySelector("#processing-halt").click();true');
else await b.evaluate('window.dispatchEvent(new Event("focus"));true');
await wait('document.body.classList.contains("signed-out")');
assert.equal(await b.evaluate('window.processingTimers().length'), 0, 'sign-out cancels the processing interval');
assert.equal(await b.evaluate('document.querySelector("#processing-halt").disabled'), true);
const count = await b.evaluate('window.processingRequests.length');
await b.evaluate(`window.dispatchEvent(new Event('hashchange'));window.dispatchEvent(new Event('focus'));
document.querySelector('#processing-halt').disabled=false;document.querySelector('#processing-halt').click();true`);
await new Promise(resolve=>setTimeout(resolve,100));
assert.equal(await b.evaluate('window.processingRequests.length'), count, 'no processing GET or POST after shared sign-out');
if(kind==='signin') assert.equal(await b.evaluate(`!!document.querySelector('#signed-out a[href^="/auth/login"]')`), true);
});
test('processing browser: Dashboard card is reachable at narrow and wide sizes', async t => {
const { b } = await setup(t);
for (const [width, height] of [[400, 1000], [1440, 1000], [1440, 420], [800, 420]]) {
await b.viewport(width, height);
await b.evaluate('document.querySelector("#processing-link").click(); document.querySelector("#processing-halt").focus(); true');
assert.equal(await b.evaluate('document.querySelector("#processing-controls").parentElement.id'), 'dashboard-processing');
assert.equal(await b.evaluate('document.activeElement.id'), 'processing-halt');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
}
});