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 htmx=await readFile(new URL('node_modules/htmx.org/dist/htmx.js',dir)); let requests=[]; const observer=`window.probe=[];for(const name of ['htmx:afterSettle','htmx:historyRestore','htmx:responseError'])document.addEventListener(name,e=>window.probe.push({name,url:location.href,cacheMiss:e.detail.cacheMiss}));`; const server=createServer((req,res)=>{ const url=new URL(req.url,'http://localhost'); const hx=req.headers['hx-request']==='true', restore=req.headers['hx-history-restore-request']==='true'; let status=200,body='',representation='asset'; res.setHeader('Cache-Control','no-store'); if(url.pathname==='/htmx.js'){ status=url.searchParams.has('fail')?503:200;body=status===200?htmx:'blocked enhancement';res.setHeader('Content-Type','application/javascript'); }else if(url.pathname==='/observe.js'){body=observer;res.setHeader('Content-Type','application/javascript');} else if(['/articles','/cache-good','/cache-bad'].includes(url.pathname)){ const topic=url.searchParams.get('topic')==='beta'?'beta':'alpha'; const fail=url.searchParams.has('scriptfail')?'&scriptfail=1':''; const main=`

${topic}

Public article ${topic}

Injected enhanced failureNative recovery
`; representation=hx&&!restore?'fragment':'full'; const full=`Public catalog
Catalog shell
${main}`; body=representation==='full'?full:main; if(hx&&url.searchParams.has('enhancedfail')){status=503;body='Injected enhancement outage';} res.setHeader('Content-Type','text/html; charset=utf-8'); if(url.pathname.startsWith('/cache-')){ res.setHeader('Cache-Control','public, max-age=3600'); const etag='"'+createHash('sha256').update(body).digest('hex')+'"';res.setHeader('ETag',etag); if(url.pathname==='/cache-good')res.setHeader('Vary','HX-Request, HX-History-Restore-Request'); if(req.headers['if-none-match']===etag){status=304;body='';} } } else{status=404;body='Not found';} requests.push({path:req.url,hx,restore,ifNoneMatch:req.headers['if-none-match']||null,status,representation,vary:res.getHeader('Vary')||null,etag:res.getHeader('ETag')||null}); res.writeHead(status);res.end(body); }); server.listen(0,'127.0.0.1');await once(server,'listening'); const base=`http://127.0.0.1:${server.address().port}`; const results=[], browsers={}; const output=process.env.FEP_RESULT_PATH||'/tmp/fep-navigation-result.json'; try{ for(const [engine,launcher] of [['chromium',chromium],['firefox',firefox]]){ const executablePath=engine==='chromium'?process.env.FEP_CHROMIUM_PATH:undefined; const browser=await launcher.launch({headless:true,...(executablePath?{executablePath}:{})}); const binary=executablePath||launcher.executablePath(); browsers[engine]={version:browser.version(),executable:binary,sha256:createHash('sha256').update(await readFile(binary)).digest('hex')}; try{ async function scenario(id,run,javaScriptEnabled=true){ requests=[];const context=await browser.newContext({javaScriptEnabled});const page=await context.newPage();page.setDefaultTimeout(4000); const result={id,engine,status:'pass'}; try{result.observation=await run(page,context);}catch(e){result.status='fail';result.error=e.message;} result.requests=[...requests];result.url=page.url();result.body=await page.locator('body').innerText().catch(()=>null); result.events=javaScriptEnabled?await page.evaluate(()=>window.probe||[]).catch(()=>[]):[]; results.push(result);await context.close(); await writeFile(output,JSON.stringify({status:'incomplete',results},null,2)+'\n'); } async function selected(p,topic){await p.locator('h1').filter({hasText:topic}).waitFor();assert.equal(new URL(p.url()).searchParams.get('topic'),topic);} async function enhanced(p){await p.waitForFunction(()=>window.htmx?.version==='2.0.11');} async function next(p,topic){await p.locator('#next').click();await selected(p,topic);await p.waitForFunction(()=>window.probe.some(e=>e.name==='htmx:afterSettle'));} await scenario('N01-no-script-native',async p=>{ await p.goto(base+'/articles?topic=alpha');await selected(p,'alpha');assert.equal(await p.locator('#shell').count(),1); await p.locator('#next').click();await selected(p,'beta');await p.locator('select').selectOption('alpha');await p.getByRole('button',{name:'Filter'}).click();await selected(p,'alpha'); assert(requests.filter(r=>r.path.startsWith('/articles')).every(r=>!r.hx&&r.representation==='full'));return {directLinkAndGetForm:true,fullShell:true}; },false); await scenario('N02-enhance-reload-direct',async(p,c)=>{ await p.goto(base+'/articles?topic=alpha');await enhanced(p);await next(p,'beta');assert(requests.some(r=>r.hx&&r.representation==='fragment')); await p.reload();await selected(p,'beta');assert.equal(await p.locator('#shell').count(),1); const fresh=await c.newPage();await fresh.goto(base+'/articles?topic=beta');assert.equal(await fresh.locator('#shell').count(),1);await fresh.close();return {pushReloadAndFreshEntry:true}; }); await scenario('N03-history-hit',async p=>{ await p.goto(base+'/articles?topic=alpha');await enhanced(p);await next(p,'beta');const before=requests.length; await p.goBack();await selected(p,'alpha');await p.waitForFunction(()=>window.probe.some(e=>e.name==='htmx:historyRestore')); await p.goForward();await selected(p,'beta'); assert(!requests.slice(before).some(r=>r.path.startsWith('/articles')));return {backForward:true,additionalArticleRequests:0}; }); await scenario('N04-history-miss',async p=>{ await p.goto(base+'/articles?topic=alpha');await enhanced(p);await next(p,'beta');await p.evaluate(()=>sessionStorage.removeItem('htmx-history-cache')); await p.goBack();await selected(p,'alpha');assert(requests.some(r=>r.restore&&r.representation==='full'&&!r.hx));return {evictedSnapshotFetchedFull:true}; }); await scenario('N05-enhancement-failure',async p=>{ await p.goto(base+'/articles?topic=alpha');await enhanced(p);await p.locator('#broken').click();await p.waitForFunction(()=>window.probe.some(e=>e.name==='htmx:responseError'));await selected(p,'alpha'); assert(requests.some(r=>r.hx&&r.status===503));await p.locator('#recover').click();await selected(p,'beta');assert.equal(await p.locator('#shell').count(),1);return {oldViewAndUrlRetained:true,explicitNativeRecovery:true}; }); await scenario('N06-script-failure',async p=>{ await p.goto(base+'/articles?topic=alpha&scriptfail=1');assert(requests.some(r=>r.path==='/htmx.js?fail=1'&&r.status===503));assert.equal(await p.evaluate(()=>typeof window.htmx),'undefined'); await p.locator('#next').click();await selected(p,'beta');await p.locator('select').selectOption('alpha');await p.getByRole('button',{name:'Filter'}).click();await selected(p,'alpha');return {nativeLinkAndFormDespiteScript503:true}; }); async function cachedFetch(p,path,hx=false){return p.evaluate(async({path,hx})=>{const r=await fetch(path,{headers:hx?{'HX-Request':'true'}:{}});return {status:r.status,text:await r.text(),etag:r.headers.get('etag')};},{path,hx});} await scenario('N07-fragment-before-document',async p=>{ await p.goto(base+'/articles?topic=alpha');const path='/cache-good?topic=beta';const frag=await cachedFetch(p,path,true);assert(!frag.text.includes('id="shell"')); await p.goto(base+path);assert.equal(await p.locator('#shell').count(),1);await selected(p,'beta');return {fragmentThenFull:true}; }); await scenario('N08-document-before-fragment',async p=>{ const path='/cache-good?topic=alpha';await p.goto(base+path);const frag=await cachedFetch(p,path,true);assert(!frag.text.includes('id="shell"'));assert(frag.text.includes('{ await p.goto(base+'/articles?topic=alpha');const path='/cache-bad?topic=beta';await cachedFetch(p,path,true);const before=requests.length;await p.goto(base+path); return {fragmentServedAsDocument:await p.locator('#shell').count()===0,additionalRequests:requests.slice(before),negativeControl:true}; }); await scenario('N10-conditional-representations',async()=>{ const path=base+'/cache-good?topic=beta';const fragment=await fetch(path,{headers:{'HX-Request':'true'}});const etag=fragment.headers.get('etag');await fragment.text(); const same=await fetch(path,{headers:{'HX-Request':'true','If-None-Match':etag}});assert.equal(same.status,304); const full=await fetch(path,{headers:{'If-None-Match':etag}});assert.equal(full.status,200);assert((await full.text()).includes('id="shell"'));assert.notEqual(full.headers.get('etag'),etag); const restore=await fetch(path,{headers:{'HX-Request':'true','HX-History-Restore-Request':'true'}});assert((await restore.text()).includes('id="shell"'));return {transport:'explicit Node HTTP requests; duplicated per browser group, not independent browser evidence',sameRepresentation:304,crossRepresentation:200,restoreFull:true}; }); }finally{await browser.close();} } }finally{await new Promise(resolve=>server.close(resolve));} const files={};for(const f of ['experiment.mjs','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 receipt={date:new Date().toISOString(),node:process.version,kind:'authored-http-fixture-with-pinned-htmx',browsers,files,results,passed:results.filter(r=>r.status==='pass').length,total:results.length}; await writeFile(output,JSON.stringify(receipt,null,2)+'\n');console.log(JSON.stringify({output,passed:receipt.passed,total:receipt.total,failures:results.filter(r=>r.status==='fail')},null,2));if(receipt.passed!==receipt.total)process.exitCode=1;