frontend-patterns/research/experiments/navigation-history/ownership-initial.mjs
tegwick e8ccb73a12 Test competing navigation ownership and review research thread
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9
2026-09-27 00:45:54 +02:00

64 lines
9.8 KiB
JavaScript

import {chromium,firefox} from 'playwright';
import {createServer} from 'node:http';
import {once} from 'node:events';
import {readFile,writeFile} from 'node:fs/promises';
import {createHash} from 'node:crypto';
import assert from 'node:assert/strict';
const dir=new URL('.',import.meta.url);
const assets=new Map(await Promise.all([['/htmx.js','node_modules/htmx.org/dist/htmx.js'],['/baseline.js','focus-policy.js'],['/guarded.js','ownership-policy.js']].map(async([url,file])=>[url,await readFile(new URL(file,dir))])));
const trace=`window.raceLog=[];for(const name of ['htmx:beforeRequest','htmx:historyCacheMiss'])document.addEventListener(name,e=>{const xhr=e.detail.xhr;xhr.addEventListener('load',()=>window.raceLog.push({event:'loaded',url:xhr.responseURL,status:xhr.status}));});`;
let requests=[],barriers=new Map();
function barrier(key){let resolve;const reached=new Promise(r=>resolve=r);const b={reached,resolve,release:null};barriers.set(key,b);return b;}
const server=createServer((req,res)=>{
const u=new URL(req.url,'http://local');res.setHeader('Cache-Control','no-store');
if(assets.has(u.pathname)){res.setHeader('Content-Type','application/javascript');res.end(assets.get(u.pathname));return;}
if(u.pathname==='/trace.js'){res.setHeader('Content-Type','application/javascript');res.end(trace);return;}
if(u.pathname!=='/articles'){res.writeHead(404);res.end('Not found');return;}
const topic=['alpha','beta','gamma'].includes(u.searchParams.get('topic'))?u.searchParams.get('topic'):'alpha';
const mode=u.searchParams.get('mode')==='guarded'?'guarded':'managed';
const hx=req.headers['hx-request']==='true',history=req.headers['hx-history-restore-request']==='true';
const status=hx&&u.searchParams.has('fail')?503:200;
const content=`<div id="payload"><h1 id="heading" tabindex="-1">${topic}</h1><p>Article ${topic}</p></div>`;
const boost='hx-boost="true" hx-target="#content" hx-select="#payload" hx-swap="innerHTML"';
const href=(t,extra='')=>`/articles?topic=${t}&mode=${mode}${extra}`;
const nav=`<nav><a id="beta" href="${href('beta')}" ${boost}>Visit beta</a><a id="old" href="${href('beta','&hold=old')}" ${boost}>Slow beta</a><a id="oldfail" href="${href('beta','&hold=old&fail=1')}" ${boost}>Slow failed beta</a><a id="new" href="${href('gamma')}" ${boost}>Visit gamma</a><a id="newfail" href="${href('gamma','&fail=1')}" ${boost}>Failed gamma</a><a id="recover" href="${href('gamma')}">Native recovery</a></nav>`;
const full=`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${topic} — Catalog</title><meta name="htmx-config" content='{"historyRestoreAsHxRequest":false}'><script src="/trace.js" defer></script><script src="/${mode==='guarded'?'guarded':'baseline'}.js" defer></script><script src="/htmx.js" defer></script></head><body>${nav}<main id="content" hx-history-elt>${content}</main><label>Persistent search <input id="search"></label><p id="feedback" role="status" aria-live="polite"></p></body></html>`;
const record={path:req.url,hx,history,status,held:false,released:false};requests.push(record);
const send=()=>{record.released=true;res.writeHead(status,{'Content-Type':'text/html'});res.end(status===503?'Injected failure':hx&&!history?content:full);};
const key=history?'history':u.searchParams.get('hold');const b=barriers.get(key);
if(b){record.held=true;b.release=send;b.resolve();}else send();
});
server.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${server.address().port}`;
const results=[],browsers={},output=process.env.FEP_RESULT_PATH||'/tmp/fep-ownership-result.json';
const inspect=p=>p.evaluate(()=>({topic:document.querySelector('#heading')?.textContent,url:location.pathname+location.search,title:document.title,focus:document.activeElement.id||document.activeElement.tagName,search:document.querySelector('#search').value,feedback:document.querySelector('#feedback').textContent}));
async function tabTo(p,id){for(let n=0;n<15;n++){if((await inspect(p)).focus===id)return;await p.keyboard.press('Tab');}throw Error(`Cannot Tab to ${id}`);}
async function activate(p,id){await tabTo(p,id);await p.keyboard.press('Enter');}
async function selected(p,t){await p.waitForFunction(t=>document.querySelector('#heading')?.textContent===t,t);await p.waitForFunction(t=>document.querySelector('#feedback').textContent===`Loaded ${t}`,t);}
async function released(p,b,history=false){b.release();b.release=null;await p.waitForFunction(history=>window.raceLog.some(e=>e.event==='loaded'&&(history?e.url.includes('topic=alpha'):e.url.includes('hold=old'))),history);}
async function held(b){await Promise.race([b.reached,new Promise((_,reject)=>setTimeout(()=>reject(Error('Server barrier not reached')),4000))]);}
function same(a,b,keys){return keys.every(k=>a[k]===b[k]);}
try{
for(const [engine,launcher]of [['chromium',chromium],['firefox',firefox]]){
const path=engine==='chromium'?process.env.FEP_CHROMIUM_PATH:undefined;const browser=await launcher.launch({headless:true,...(path?{executablePath:path}:{})});
browsers[engine]={version:browser.version(),executable:path||launcher.executablePath(),sha256:createHash('sha256').update(await readFile(path||launcher.executablePath())).digest('hex')};
try{for(const mode of ['managed','guarded']){
async function scenario(id,fn){
requests=[];barriers=new Map();const context=await browser.newContext();const p=await context.newPage();p.setDefaultTimeout(4500);
const r={id,engine,mode,status:'pass'};
try{await p.goto(base+`/articles?topic=alpha&mode=${mode}`);await p.waitForFunction(()=>window.htmx?.version==='2.0.11');Object.assign(r,await fn(p));r.obligationsMet=Object.values(r.obligations).every(Boolean);if(mode==='guarded')assert(r.obligationsMet,JSON.stringify(r.obligations));}
catch(e){r.status='fail';r.error=e.message;}
r.final=await inspect(p).catch(()=>null);r.events=await p.evaluate(()=>window.probe).catch(()=>[]);r.completions=await p.evaluate(()=>window.raceLog).catch(()=>[]);r.requests=[...requests];results.push(r);
for(const b of barriers.values())if(b.release){b.release();b.release=null;}await context.close();await writeFile(output,JSON.stringify({status:'incomplete',results},null,2)+'\n');
}
async function backMiss(p){await activate(p,'beta');await selected(p,'beta');await p.evaluate(()=>sessionStorage.removeItem('htmx-history-cache'));const b=barrier('history');await p.goBack();await held(b);return b;}
await scenario('O01-history-focus-moved',async p=>{const b=await backMiss(p);await tabTo(p,'search');await p.keyboard.type('new task');const before=await inspect(p);await released(p,b,true);const after=await inspect(p);return {before,after,obligations:{selection:after.topic==='alpha'&&new URL(base+after.url).searchParams.get('topic')==='alpha',title:after.title==='alpha — Catalog',focus:after.focus==='search',typedText:after.search==='new task'}};});
await scenario('O02-history-superseded',async p=>{const b=await backMiss(p);await activate(p,'new');await selected(p,'gamma');const before=await inspect(p);await released(p,b,true);const after=await inspect(p);return {before,after,obligations:{newerSelection:same(before,after,['topic','url','title']),noFeedbackOrFocusTheft:same(before,after,['feedback','focus'])}};});
await scenario('O03-old-success',async p=>{const b=barrier('old');await activate(p,'old');await held(b);await activate(p,'new');await selected(p,'gamma');const before=await inspect(p);await released(p,b);const after=await inspect(p);return {before,after,obligations:{newerSelection:same(before,after,['topic','url','title']),noFeedbackOrFocusTheft:same(before,after,['feedback','focus'])}};});
await scenario('O04-old-error',async p=>{const b=barrier('old');await activate(p,'oldfail');await held(b);await activate(p,'new');await selected(p,'gamma');const before=await inspect(p);await released(p,b);const after=await inspect(p);return {before,after,obligations:{newerSelection:same(before,after,['topic','url','title']),noObsoleteFeedback:same(before,after,['feedback','focus'])}};});
await scenario('O05-newer-fails',async p=>{const b=barrier('old');await activate(p,'old');await held(b);await activate(p,'newfail');await p.waitForFunction(()=>document.querySelector('#feedback').textContent.includes('Navigation failed'));const before=await inspect(p);assert.equal(before.topic,'alpha');await released(p,b);const after=await inspect(p);return {before,after,obligations:{committedView:same(before,after,['topic','url','title']),newestFailureRetained:same(before,after,['feedback','focus'])}};});
await scenario('O06-history-newer-than-request',async p=>{await activate(p,'beta');await selected(p,'beta');const b=barrier('old');await activate(p,'old');await held(b);await p.goBack();await selected(p,'alpha');const before=await inspect(p);await released(p,b);const after=await inspect(p);return {before,after,obligations:{historySelection:same(before,after,['topic','url','title']),historyContinuation:same(before,after,['feedback','focus'])}};});
}}finally{await browser.close();}
}
}finally{for(const b of barriers.values())if(b.release)b.release();await new Promise(resolve=>server.close(resolve));}
const files={};for(const f of ['ownership.mjs','ownership-policy.js','focus-policy.js','package.json','package-lock.json','node_modules/htmx.org/dist/htmx.js'])files[f]=createHash('sha256').update(await readFile(new URL(f,dir))).digest('hex');
const r={date:new Date().toISOString(),node:process.version,kind:'ordered-navigation-completion-races-in-authored-htmx-fixture',files,browsers,results,completed:results.filter(x=>x.status==='pass').length,total:results.length,obligationsMet:results.filter(x=>x.obligationsMet).length};await writeFile(output,JSON.stringify(r,null,2)+'\n');console.log(JSON.stringify({output,completed:r.completed,total:r.total,obligationsMet:r.obligationsMet,failures:results.filter(x=>x.status==='fail')},null,2));if(r.completed!==r.total)process.exitCode=1;