frontend-patterns/research/experiments/navigation-history/focus.mjs
tegwick 26d7c4523b Measure keyboard continuation and navigation feedback
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9
2026-09-27 00:22:14 +02:00

74 lines
7.9 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 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('<p>Background news updated</p>');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=`<main id="content" hx-history-elt><h1 id="heading" tabindex="-1">${topic}</h1><p>Article ${topic}</p><a id="next" href="${target}" ${boost}>Other topic</a><a id="broken" href="${target}&fail=1" ${boost}>Fail enhanced navigation</a><a id="recover" href="${target}&fail=1">Native recovery</a><a id="slow" href="${target}&slow=1" ${boost}>Delayed navigation</a></main>`;
const body=hx?content:`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${topic} — Catalog</title><meta name="htmx-config" content='{"historyRestoreAsHxRequest":false}'><script src="/policy.js" defer></script><script src="/htmx.js" defer></script></head><body>${content}<label>Persistent search <input id="search"></label><p id="feedback" role="status" aria-live="polite" aria-atomic="true"></p><aside id="news">Initial news</aside></body></html>`;
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;