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)); const policy=await readFile(new URL('focus-policy.js',dir)); let requests=[],release=null,heldResolve=null; const server=createServer((req,res)=>{ const u=new URL(req.url,'http://local');res.setHeader('Cache-Control','no-store'); const hx=req.headers['hx-request']==='true';requests.push({path:req.url,hx,restore:req.headers['hx-history-restore-request']==='true'}); if(u.pathname==='/htmx.js'||u.pathname==='/policy.js'){res.setHeader('Content-Type','application/javascript');res.end(u.pathname==='/htmx.js'?htmx:policy);return;} if(u.pathname==='/news'){res.end('

Background news updated

');return;} if(u.pathname!=='/articles'){res.writeHead(404);res.end('Not found');return;} const topic=u.searchParams.get('topic')==='beta'?'beta':'alpha',mode=u.searchParams.get('mode')==='managed'?'managed':'raw'; const target=`/articles?topic=${topic==='alpha'?'beta':'alpha'}&mode=${mode}`; const boost='hx-boost="true" hx-target="#content" hx-select="#content" hx-swap="outerHTML"'; const content=`

${topic}

Article ${topic}

Fail enhanced navigationNative recoveryDelayed navigation
`; const body=hx?content:`${topic} — Catalog${content}

`; const send=()=>{res.writeHead(hx&&u.searchParams.has('fail')?503:200,{'Content-Type':'text/html'});res.end(hx&&u.searchParams.has('fail')?'Outage':body);}; if(hx&&u.searchParams.has('slow')){release=send;heldResolve?.();}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={};const output=process.env.FEP_RESULT_PATH||'/tmp/fep-focus-result.json'; async function inspect(p){return p.evaluate(()=>({focus:document.activeElement.id||document.activeElement.tagName,title:document.title,url:location.href,heading:document.querySelector('h1')?.textContent,feedback:document.querySelector('#feedback')?.textContent,statusRole:document.querySelector('#feedback')?.getAttribute('role'),live:document.querySelector('#feedback')?.getAttribute('aria-live')}));} async function tabTo(p,id){for(let i=0;i<12;i++){if((await inspect(p)).focus===id)return;await p.keyboard.press('Tab');}throw Error(`Tab failed to reach ${id}`);} async function selected(p,topic){await p.waitForFunction(t=>document.querySelector('h1')?.textContent===t,topic);assert.equal(new URL(p.url()).searchParams.get('topic'),topic);} async function settled(p){await p.waitForFunction(()=>window.probe.some(e=>e.name==='htmx:afterSettle'));} 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{ async function scenario(id,mode,fn){ requests=[];release=null;const context=await browser.newContext({javaScriptEnabled:mode!=='native'});const p=await context.newPage();p.setDefaultTimeout(4000); const r={id,engine,mode,status:'pass'}; try{await p.goto(base+`/articles?topic=alpha&mode=${mode}`);if(mode!=='native')await p.waitForFunction(()=>window.htmx?.version==='2.0.11');r.observation=await fn(p);} 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.requests=[...requests];results.push(r); if(release){release();release=null;}heldResolve=null;await context.close();await writeFile(output,JSON.stringify({status:'incomplete',results},null,2)+'\n'); } await scenario('K01-native-keyboard','native',async p=>{await tabTo(p,'next');await p.keyboard.press('Enter');await selected(p,'beta');assert.equal(await p.title(),'beta — Catalog');return {keyboardLink:true};}); for(const mode of ['raw','managed']){ await scenario('K02-enhanced-keyboard',mode,async p=>{ await tabTo(p,'next');await p.keyboard.press('Enter');await selected(p,'beta');await settled(p);const o=await inspect(p); o.headingContinuation=o.focus==='heading';o.updatedTitle=o.title==='beta — Catalog';o.completionFeedback=o.feedback==='Loaded beta'; if(mode==='managed')assert(o.headingContinuation&&o.updatedTitle&&o.completionFeedback);return o; }); await scenario('K03-error-feedback-recovery',mode,async p=>{ await tabTo(p,'broken');await p.keyboard.press('Enter');await p.waitForFunction(()=>window.probe.some(e=>e.name==='htmx:responseError'));await selected(p,'alpha');const o=await inspect(p);assert.equal(o.focus,'broken'); o.failureFeedback=o.feedback.includes('Navigation failed');if(mode==='managed'){assert(o.failureFeedback);assert.equal(o.statusRole,'status');assert.equal(o.live,'polite');} await tabTo(p,'recover');await p.keyboard.press('Enter');await selected(p,'beta');o.nativeRecovery=true;return o; }); await scenario('K04-delayed-new-focus',mode,async p=>{ const held=new Promise(resolve=>{heldResolve=resolve;});await tabTo(p,'slow');await p.keyboard.press('Enter');await held;await tabTo(p,'search'); release();release=null;await selected(p,'beta');await settled(p);const o=await inspect(p);assert.equal(o.focus,'search');if(mode==='managed')assert.equal(o.feedback,'Loaded beta');return o; }); await scenario('K05-history-continuation',mode,async p=>{ await tabTo(p,'next');await p.keyboard.press('Enter');await selected(p,'beta');await settled(p);await p.goBack();await selected(p,'alpha');await p.waitForFunction(()=>window.probe.some(e=>e.name==='htmx:historyRestore')); const o=await inspect(p);if(mode==='managed'){assert.equal(o.focus,'heading');assert.equal(o.title,'alpha — Catalog');assert.equal(o.feedback,'Loaded alpha');}return o; }); await scenario('K06-background-no-theft',mode,async p=>{ await tabTo(p,'search');await p.evaluate(()=>htmx.ajax('GET','/news',{target:'#news',swap:'innerHTML'}));await p.locator('#news').filter({hasText:'Background news updated'}).waitFor(); const o=await inspect(p);assert.equal(o.focus,'search');assert.equal(o.feedback,'');assert.equal(o.title,'alpha — Catalog');return o; }); } }finally{await browser.close();} } }finally{if(release)release();await new Promise(resolve=>server.close(resolve));} const files={};for(const f of ['focus.mjs','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:'keyboard-focus-dom-observation-not-assistive-technology-validation',files,browsers,results,passed:results.filter(x=>x.status==='pass').length,total:results.length};await writeFile(output,JSON.stringify(r,null,2)+'\n');console.log(JSON.stringify({output,passed:r.passed,total:r.total,failures:results.filter(x=>x.status==='fail')},null,2));if(r.passed!==r.total)process.exitCode=1;