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('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: static nav controls keep Stop visible, ordered and keyboard reachable', 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('scrollTo(0,0);document.querySelector(".tree").scrollTop=0;true'); const layout = await b.evaluate(`(() => { const controls = document.querySelector('#processing-controls'), stop = document.querySelector('#processing-halt'); const c = controls.getBoundingClientRect(), n = document.querySelector('.tree').getBoundingClientRect(); const s = stop.getBoundingClientRect(), bar = document.querySelector('.cmdbar').getBoundingClientRect(); return { width: innerWidth, height: innerHeight, controls: c.height, tree: n.height, main: document.querySelector('#main').getBoundingClientRect().height, top: s.top, bottom: s.bottom, barBottom: bar.bottom, projectsTop: document.querySelector('#projects').getBoundingClientRect().top, order: getComputedStyle(stop).order, visible: stop.getClientRects().length > 0 && getComputedStyle(stop).visibility === 'visible' && getComputedStyle(stop).display !== 'none' }; })()`); console.log('ROW191_LAYOUT', JSON.stringify(layout)); assert.ok(layout.visible && layout.top >= layout.barBottom && layout.bottom <= height, 'Stop is visible below the command bar and inside the viewport on load'); assert.equal(layout.order, '0', 'nav button rules do not reorder Stop'); if (width === 400) assert.ok(layout.top < layout.projectsTop, 'Stop precedes Projects on mobile'); await b.evaluate('document.querySelector("#processing-halt").focus();true'); assert.equal(await b.evaluate('document.activeElement.id'), 'processing-halt'); } });