import {chromium,firefox} from 'playwright'; import {createServer} from 'node:http'; import {once} from 'node:events'; import {readFile,writeFile} from 'node:fs/promises'; import {fileURLToPath} from 'node:url'; import {createHash} from 'node:crypto'; import assert from 'node:assert/strict'; const dir=fileURLToPath(new URL('.',import.meta.url)); const names=['reload-host.js','reports-r1.js','reports-r2.js']; const assets=new Map(await Promise.all(names.map(async n=>['/'+n,await readFile(dir+n)]))); let state,trace,held,holdNext; function reset(){state={revision:1,reports:'/reports-r1.js',account:'A1',draftSchema:1};trace=[];held=null;holdNext=false;} reset(); const copy=()=>({...state}); const json=(res,code,value)=>{res.writeHead(code,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify(value));}; const server=createServer(async(req,res)=>{ const path=new URL(req.url,'http://local').pathname;res.setHeader('Cache-Control','no-store'); if(path==='/activation'&&req.method==='GET'){ const snapshot=copy();trace.push({event:'read',snapshot}); if(holdNext){holdNext=false;held=()=>json(res,200,snapshot);}else json(res,200,snapshot); }else if((path==='/activation'||path==='/unsafe')&&req.method==='PUT'){ let body='';for await(const chunk of req)body+=chunk;const next=JSON.parse(body); // Single-process model: no await between revision comparison and replacement. if(path==='/activation'&&req.headers['if-match']!==String(state.revision)){ trace.push({event:'conflict',expected:req.headers['if-match'],actual:state.revision});json(res,409,copy());return; } state={...next,revision:state.revision+1};trace.push({event:'commit',path,state:copy()});json(res,200,copy()); }else if(path==='/'){ const snapshot=copy();trace.push({event:'document',snapshot});res.setHeader('Content-Type','text/html');res.end(`Safe reload probe

`); }else if(assets.has(path)){res.setHeader('Content-Type','application/javascript');res.end(assets.get(path));} else{res.writeHead(404);res.end('missing');} }); server.listen(0,'127.0.0.1');await once(server,'listening');const url=`http://127.0.0.1:${server.address().port}`; const results=[]; async function put(value,rev,unsafe=false){const r=await fetch(url+(unsafe?'/unsafe':'/activation'),{method:'PUT',headers:{'Content-Type':'application/json','If-Match':String(rev)},body:JSON.stringify(value)});return {status:r.status,body:await r.json()};} const get=()=>fetch(url+'/activation').then(r=>r.json()); async function control(id,run){reset();const r={id,kind:'HTTP-control-plane',status:'pass'};try{r.observation=await run();}catch(e){r.status='fail';r.error=e.message;}r.trace=[...trace];results.push(r);} try{ await control('C01-blind-update-loses-neighbor',async()=>{const [a,b]=await Promise.all([get(),get()]);await put({...a,reports:'/reports-r2.js'},a.revision,true);await put({...b,account:'A2'},b.revision,true);assert.equal(state.reports,'/reports-r1.js');assert.equal(state.account,'A2');return {lostReportsUpdate:true};}); await control('C02-stale-disjoint-update-refetch',async()=>{const [a,b]=await Promise.all([get(),get()]);assert.equal((await put({...a,reports:'/reports-r2.js'},a.revision)).status,200);assert.equal((await put({...b,account:'A2'},b.revision)).status,409);const current=await get();assert.equal((await put({...current,account:'A2'},current.revision)).status,200);assert.equal(state.reports,'/reports-r2.js');assert.equal(state.account,'A2');return {bothUpdatesRetained:true,explicitRefetchAndReapply:true};}); await control('C03-same-feature-needs-decision',async()=>{const [a,b]=await Promise.all([get(),get()]);await put({...a,reports:'/reports-r2.js'},a.revision);const conflict=await put({...b,reports:'/reports-r1.js'},b.revision);assert.equal(conflict.status,409);assert.equal(state.reports,'/reports-r2.js');return {staleRollbackRejected:true,noAutomaticOverwrite:true};}); for(const[engine,launcher]of [['chromium',chromium],['firefox',firefox]]){ const browser=await launcher.launch({headless:true,...(engine==='chromium'&&process.env.FEP_CHROMIUM_PATH?{executablePath:process.env.FEP_CHROMIUM_PATH}:{})}); try{ async function scenario(id,run){reset();const p=await browser.newPage();p.setDefaultTimeout(2500);const errors=[];p.on('pageerror',e=>errors.push(e.message));const r={id,engine,browser:browser.version(),status:'pass'}; try{await p.goto(url);await p.waitForFunction(()=>window.ready);await p.locator('#draft').fill('unfinished proposal');await put({...state,reports:'/reports-r2.js'},state.revision);r.observation=await run(p);assert.deepEqual(errors,[]);}catch(e){r.status='fail';r.error=e.message;} r.snapshot=await p.evaluate(()=>({draft:document.getElementById('draft').value,status:document.getElementById('status').textContent,version:document.getElementById('version').textContent,readOnly:document.getElementById('draft').readOnly,saved:sessionStorage.getItem('fep-review-draft')}));r.errors=errors;r.trace=[...trace];results.push(r);if(held){held();held=null;}await p.close();await writeFile('/tmp/fep-conflicts-reload-checkpoint.json',JSON.stringify({status:'incomplete',results},null,2)+'\n');} async function reload(p,button){await Promise.all([p.waitForEvent('load'),p.locator(button).click()]);await p.waitForFunction(()=>window.ready);} const draft= p=>p.locator('#draft').inputValue(); await scenario('R01-unguarded-reload-loses-draft',async p=>{await p.reload();await p.waitForFunction(()=>window.ready);assert.equal(await draft(p),'');assert.equal(await p.locator('#version').textContent(),'R2');return {draftLost:true,negativeControl:true};}); await scenario('R02-defer-keeps-work',async p=>{await p.locator('#stay').click();assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R1');return {declinedReload:true};}); await scenario('R03-preserve-and-restore',async p=>{await reload(p,'#preserve');assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R2');const s=await p.evaluate(()=>JSON.parse(sessionStorage.getItem('fep-review-draft')));assert.equal(s.base,'record-v1');return {restored:true,basePreserved:true};}); await scenario('R04-storage-failure-blocks',async p=>{await p.evaluate(()=>window.failStorage=true);await p.locator('#preserve').click();await p.locator('#status').filter({hasText:'storage failed'}).waitFor();assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R1');return {reloadBlocked:true};}); await scenario('R05-schema-mismatch-blocks',async p=>{await put({...state,draftSchema:2},state.revision);await p.locator('#preserve').click();await p.locator('#status').filter({hasText:'incompatible draft schema'}).waitFor();assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R1');return {reloadBlocked:true};}); await scenario('R06-explicit-discard',async p=>{await p.evaluate(()=>sessionStorage.setItem('fep-review-draft',JSON.stringify({schema:1,text:'older saved draft',base:'record-v1'})));await reload(p,'#discard');assert.equal(await draft(p),'');assert.equal(await p.evaluate(()=>sessionStorage.getItem('fep-review-draft')),null);assert.equal(await p.locator('#version').textContent(),'R2');return {explicitlyDiscarded:true};}); await scenario('R07-unknown-write-blocks',async p=>{await p.evaluate(()=>window.pendingUnknown=true);await p.locator('#preserve').click();await p.locator('#status').filter({hasText:'unknown operation'}).waitFor();assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R1');return {uncertainOutcomeNotDiscarded:true};}); await scenario('R08-edit-during-preflight',async p=>{holdNext=true;await p.locator('#preserve').click();await untilHeld();await p.locator('#draft').fill('newer proposal while checking');const release=held;held=null;const done=p.waitForEvent('load');release();await done;await p.waitForFunction(()=>window.ready);assert.equal(await draft(p),'newer proposal while checking');return {latestEditPreserved:true};}); await scenario('R09-activation-changes-after-check',async p=>{holdNext=true;await p.locator('#preserve').click();await untilHeld();await put({...state,draftSchema:2},state.revision);const release=held;held=null;const done=p.waitForEvent('load');release();await done;await p.waitForFunction(()=>window.ready);assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#draft').getAttribute('readonly'),'');await p.locator('#status').filter({hasText:'Recovery required'}).waitFor();return {startupRevalidation:true,draftRetainedButNotEditable:true};}); }finally{await browser.close();} } }finally{if(held)held();await new Promise(resolve=>server.close(resolve));} async function untilHeld(){for(let i=0;i<100;i++){if(held)return;await new Promise(r=>setTimeout(r,10));}throw new Error('preflight barrier not reached');} const files={};for(const f of [...names,'conflicts-reload.mjs','package.json','package-lock.json'])files[f]=createHash('sha256').update(await readFile(dir+f)).digest('hex');const output=process.env.FEP_RESULT_PATH||'/tmp/fep-conflicts-reload.json';const result={date:new Date().toISOString(),node:process.version,files,results,passed:results.filter(x=>x.status==='pass').length,total:results.length};await writeFile(output,JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify({output,passed:result.passed,total:result.total,failures:results.filter(x=>x.status==='fail')},null,2));if(result.passed!==result.total)process.exitCode=1;