Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9
74 lines
7.9 KiB
JavaScript
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;
|