fix(a11y): reconcile responsive layouts and multilingual UI

This commit is contained in:
DeCentN2Madness committed 2026-10-15 12:18:44 -07:00
1 parent 8e64d32209
commit 59911eb929
59 files changed
+27178 -187

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
/** Bounded generated-HTML rules, not a browser/WCAG conformance audit. No network. */
import fs from 'node:fs/promises';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import fg from 'fast-glob';
import matter from 'gray-matter';
import {load} from 'cheerio';
import {createPublicationManifest} from '../src/utils/publication-policy.ts';
import {createLibraryCatalog} from '../src/utils/library-catalog.ts';
import {createLibraryPaginationPublications} from '../src/utils/library-pagination.ts';
import {createAuthorArchivePublications} from '../src/utils/author-archives.ts';
import {createLibrarySearchPublication} from '../src/utils/search-publication.ts';
const root=fileURLToPath(new URL('../',import.meta.url));
const out=process.argv[2];
if(!out) throw Error('Provide an explicit output JSON path (prefer /tmp). Build staging first.');
const records=await Promise.all((await fg('src/content/{pages,blog,authors}/**/*.{md,mdx}',{cwd:root})).sort().map(async file=>{const {data,content}=matter(await fs.readFile(path.join(root,file),'utf8'));return {collection:file.split('/')[2],id:file.split('/').slice(3).join('/').replace(/\.mdx?$/,''),data,body:content};}));
const site=JSON.parse(await fs.readFile(path.join(root,'src/data/site.json'))).url;
const content=createPublicationManifest(records.filter(r=>r.collection!=='authors'),site),authors=records.filter(r=>r.collection==='authors');
const catalog=createLibraryCatalog(content,authors),pagination=createLibraryPaginationPublications(catalog,content,site),archives=createAuthorArchivePublications(catalog,authors,[...content,...pagination],site);
const manifest=[...content,...pagination,...archives,createLibrarySearchPublication(site)].filter(p=>p.eligible);
const files=await fg('dist/**/*.html',{cwd:root});
const pages=new Map(await Promise.all(files.map(async file=>{const route='/'+file.replace(/^dist\//,'').replace(/(^|\/)index.html$/,'').replace(/\/$/,'');return [route,load(await fs.readFile(path.join(root,file),'utf8'))];})));
const text=s=>s.replace(/\s+/g,' ').trim();const rows=[];
for(const p of manifest){const $=pages.get(p.route),errors=[],review=[];const check=(ok,rule)=>{if(!ok) errors.push(rule);};
if(!$){rows.push({route:p.route,errors:['missing-route'],review});continue;}
check($('html').attr('lang')===p.entry.data.lang,'language');check($('html').attr('dir')===(p.entry.data.lang==='ar'?'rtl':'ltr'),'direction');
check($('title').length===1&&text($('title').text()).length>0,'title');check($('main h1').length===1&&text($('main h1').text()).length>0,'main-h1');check($('main').length===1,'main-landmark');
check($('link[rel=canonical]').length===1&&$('link[rel=canonical]').attr('href')===p.canonical,'canonical');check($('meta[name=robots]').attr('content')==='noindex,nofollow','staging-robots');check($('link[hreflang]').length===0,'staging-hreflang');
const ids=new Map();$('[id]').each((_,e)=>{const id=$(e).attr('id');ids.set(id,(ids.get(id)||0)+1);});check([...ids.values()].every(n=>n===1),'duplicate-id');
const name=e=>text($(e).attr('aria-label')||($(e).attr('aria-labelledby')||'').split(/\s+/).map(id=>$('[id]').filter((_,el)=>$(el).attr('id')===id).text()).join(' ')||$(e).text()||$(e).find('img').attr('alt')||$(e).attr('title')||'');
$('input:not([type=hidden]),select,textarea').each((_,e)=>{const id=$(e).attr('id');check(Boolean(name(e)||$(e).closest('label').length||(id&&$('label').toArray().some(l=>$(l).attr('for')===id))),'unlabelled-field');});
$('button,summary').each((_,e)=>check(Boolean(name(e)),'unnamed-control'));
$('img').each((_,e)=>check($(e).attr('alt')!==undefined,'missing-alt'));
$('[aria-labelledby],[aria-describedby]').each((_,e)=>{for(const attr of ['aria-labelledby','aria-describedby']) for(const id of ($(e).attr(attr)||'').split(/\s+/).filter(Boolean))check(ids.has(id),'missing-aria-target');});
$('a[href]').each((_,e)=>{const href=$(e).attr('href');if(!href||/^(mailto:|tel:|javascript:)/.test(href))return;const u=new URL(href,site+p.route);if(u.origin!==new URL(site).origin||!u.hash||u.hash.startsWith('#:~:text='))return;const dest=pages.get(u.pathname.replace(/\/$/,'')||'/');if(!dest)return;const id=decodeURIComponent(u.hash.slice(1));check(dest('[id]').toArray().some(el=>dest(el).attr('id')===id),'missing-fragment');});
for(const e of $('img[src],script[src],link[rel=stylesheet][href]').toArray()){const url=$(e).attr('src')||$(e).attr('href');if(url.startsWith('/'))check(await fs.access(path.join(root,'dist',decodeURIComponent(url.split(/[?#]/)[0]))).then(()=>true,()=>false),'missing-essential-asset');}
let prev=0;$('main h1,main h2,main h3,main h4,main h5,main h6').each((_,e)=>{const level=Number(e.tagName.slice(1));if(prev&&level>prev+1)review.push({rule:'heading-level-jump',heading:text($(e).text()),from:prev,to:level});prev=level;});
$('table').each((_,e)=>{if(!$(e).find('th').length)review.push({rule:'table-without-header'});});
const forms=$('form').toArray().map(e=>({action:$(e).attr('action'),method:$(e).attr('method')||'get',disabledSubmit:$(e).find('[type=submit][disabled]').length}));
const diagrams=$('.article-diagram-scroll').toArray().map(e=>({name:$(e).attr('aria-label'),tabindex:$(e).attr('tabindex'),role:$(e).attr('role'),src:$(e).find('img').attr('src'),alt:$(e).find('img').attr('alt')}));
for(const d of diagrams)check(d.name&&d.alt&&d.tabindex==='0'&&d.role==='region','diagram-semantics');
rows.push({route:p.route,locale:p.entry.data.lang,title:text($('title').text()),h1:text($('main h1').text()),errors:[...new Set(errors)],review,forms,diagrams,browser:'Not tested',reviewerAcceptance:'Pending'});
}
const extras=[...pages.keys()].filter(r=>!manifest.some(p=>p.route===r));const unexpected=extras.filter(r=>r!=='/404.html');
const report={ruleSet:'AIA phase8-static-v1 (Cheerio DOM heuristics; not axe or WCAG certification)',generatedAt:new Date().toISOString(),routeCount:rows.length,htmlCount:files.length,utilityRoutes:extras,unexpectedRoutes:unexpected,arabicHomePublished:pages.has('/ar'),failures:rows.filter(r=>r.errors.length).length,reviewFindings:rows.reduce((n,r)=>n+r.review.length,0),rows};
await fs.writeFile(out,JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify({...report,rows:undefined},null,2));
if(report.failures||unexpected.length||report.arabicHomePublished) process.exitCode=1;
+81 -1
View File
@@ -1052,7 +1052,9 @@ test('Phase 7 privacy page preserves policy sections and reciprocal eligible nav
assert.equal($('link[rel=canonical]').attr('href'), `${site}/es/privacy-policy`);
assert.equal($('meta[name=robots]').attr('content'), mode === 'indexing' ? 'index,follow' : 'noindex,nofollow');
assert.equal($('link[hreflang]').length, mode === 'indexing' ? 3 : 0);
assert.equal($('.source-page h2').length, 10);
assert.equal($('.source-page h2').length, 11);
assert.equal($('#información-que-recopilamos').next().text(), 'Podemos recopilar los siguientes tipos de información:');
assert.equal($('#información-que-recopilamos').next().next('ul').children('li').length, 4);
assert.equal($('.source-page li h2').length, 0);
assert.match($('.source-page').text(), /19 de febrero de 2025/);
for (const url of ['https://www.hhs.gov/hipaa/for-professionals/privacy/laws-regulations/index.html','https://cphs.berkeley.edu/hipaa/hipaa18.html']) assert.equal($(`a[href="${url}"]`).length,1);
@@ -1100,3 +1102,81 @@ test('Phase 7 Spanish reinforcement tip keeps meaning and publication identity',
assert.equal(item.entry.data.translationKey, 'positive-reinforcement-techniques');
assert.equal(item.entry.data.author, 'rula-diab');
});
test('Arabic article and archive chrome uses localized names without changing English or Spanish labels', () => {
for (const { html } of artifacts.values()) {
for (const [route, source] of html) {
if (!route.startsWith('/ar/library')) continue;
const $ = load(source);
assert.equal($('html').attr('dir'), 'rtl');
$('[data-like]').each((_, button) => {
assert.equal($(button).attr('aria-label'), 'الإعجاب بهذا المقال', route);
assert.equal($(button).attr('aria-pressed'), 'false');
});
$('.like-view-counter').each((_, counter) => {
assert.match($(counter).text(), /مشاهدات/, route);
assert.equal($(counter).find('[data-views]').attr('dir'), 'ltr');
assert.equal($(counter).find('.like-group').attr('aria-label'), 'الإعجابات');
});
if ($('.post-pagination').length) assert.equal($('.post-pagination').attr('aria-label'), 'التنقل بين المقالات');
if ($('.related-posts').length) {
assert.equal($('.related-posts h2').text(), 'مقالات ذات صلة');
$('.related-post__image-link').each((_, a) => assert.match($(a).attr('aria-label'), /^قراءة المقال كاملاً: /));
}
assert.doesNotMatch($('.blog-post-footer').text(), /Previous Post|Next Post|Similar Blog Posts/);
}
for (const prefix of ['', '/es']) {
const $ = load(html.get(`${prefix}/library/autism-self-advocacy-skills-aba`));
assert.equal($('[data-like]').first().attr('aria-label'), 'Like this article');
assert.equal($('.related-posts h2').text(), 'Similar Blog Posts');
assert.match($('.like-view-counter').text(), /views/);
}
}
});
test('peer sections have H2 semantics with stable fragments and searchable heading text', () => {
for (const { html, indexes } of artifacts.values()) {
const dtt = load(html.get('/library/aba-therapy-dtt-net-guide'));
const ids = ['insight-into-discrete-trial-training-dtt', 'exploring-natural-environment-training-net', 'combining-strengths-for-comprehensive-development'];
for (const id of ids) assert.equal(dtt(`h2.heading-size-h3#${id}`).length, 1);
assert.equal(dtt('.blog-post-body h3').length, 0);
const search = indexes.en.find(row => row.url === '/library/aba-therapy-dtt-net-guide');
assert.ok(search.headings.includes('Insight into Discrete Trial Training (DTT):'));
const consultation = load(html.get('/schedule-consultation'));
assert.equal(consultation('h2.heading-size-h3#start-here').text(), 'Start Here');
}
});
test('Phase 8 Spanish homepage adds HSA/FSA once between ESA and financial help without replacing English artwork', () => {
for (const [route, asset] of [['/', 'hsa-fsa-accepted.png'], ['/es', 'hsa-fsa-accepted-es.png']]) {
const $ = load(artifacts.get('staging').html.get(route));
const section = $('.hsa-fsa-section');
assert.equal(section.length, 1);
assert.equal(section.find('img').attr('src'), `/assets/images/${asset}`);
assert.ok(section.find('img').attr('alt'));
if (route === '/es') {
assert.match(section.find('h2').text(), /Cuentas de ahorros/);
assert.match(section.prev().text(), /ESA/);
assert.match(section.next().text(), /Necesita ayuda/);
assert.match(section.find('img').attr('alt'), /APROBADO/);
}
}
});
test('Phase 8 Tour retains nine ordered source photos and captions with a complete static fallback', async () => {
const gallery = JSON.parse(await readFile(path.join(project, 'src/data/tour-gallery.json'), 'utf8'));
const $ = load(artifacts.get('staging').html.get('/tour'));
assert.equal(gallery.length, 9);
assert.equal($('[data-slide]').length, 9);
assert.deepEqual($('[data-slide] img').map((_, e) => $(e).attr('src')).get(), gallery.map(s => s.src));
assert.deepEqual($('[data-slide] figcaption').map((_, e) => $(e).text()).get(), gallery.map(s => s.caption));
assert.equal($('[data-slide][hidden]').length, 0);
assert.equal($('[data-thumbnail]').length, 9);
assert.equal($('[data-previous]').attr('aria-label'), 'Previous slide');
assert.equal($('[data-next]').attr('aria-label'), 'Next slide');
assert.equal($('[data-carousel-status]').attr('role'), 'status');
assert.equal($('[data-carousel-controls]:not([hidden])').length, 0);
assert.ok($('[data-image-carousel]').attr('aria-label'));
assert.equal(manifest.filter(p => p.route === '/tour').length, 1);
assert.equal($('.source-page a[href="/client-consultation"]').text(), 'Schedule a Tour Today');
});
+104
View File
@@ -0,0 +1,104 @@
/** Optional HOST-TERMINAL runner. Requires an existing Playwright installation/browser.
* No dependency installation, live-source navigation or production requests.
* First run audit-phase8.mjs against final staging output to obtain the route manifest.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import {pathToFileURL} from 'node:url';
const [inventoryFile, outputDirectory] = process.argv.slice(2);
if (!inventoryFile || !outputDirectory) throw Error('Usage: node tools/phase8-browser.mjs /tmp/static.json /tmp/new-evidence-dir');
const inventory = JSON.parse(await fs.readFile(inventoryFile, 'utf8'));
if (inventory.failures || inventory.arabicHomePublished || inventory.routeCount !== 122) throw Error('Resolve static inventory failures first.');
const output = path.resolve(outputDirectory);
if (!output.startsWith('/tmp/')) throw Error('Keep browser artifacts in a new /tmp directory, outside the repository.');
await fs.mkdir(output); // Refuse to overwrite a prior evidence directory.
const moduleName = process.env.PLAYWRIGHT_MODULE ? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href : 'playwright';
const {chromium} = await import(moduleName); // No npx/install and no project dependency change.
const manual = process.env.PHASE8_MANUAL === '1';
const browser = await chromium.launch({headless: !manual});
const origin = 'http://127.0.0.1:4321';
const evidence = {startedAt: new Date().toISOString(), browser: browser.version(), origin, dpr: 1, screenshots: [], smoke: [], keyboardObservations: [], noJS: [], blocked: [], runtimeErrors: [], consoleErrors: [], productionAttempt: false, reviewerAcceptance: 'Pending'};
const candidates = ['/', '/es', '/services', '/es/services', '/aba-therapy', '/es/aba-therapy', '/autism-evaluations', '/es/autism-evaluations', '/learner-social-club', '/es/learner-social-club', '/aba-therapy-intake-process', '/es/aba-therapy-intake-process', '/contact', '/es/contact', '/client-consultation', '/es/client-consultation', '/referrals', '/privacy-policy', '/es/privacy-policy', '/team', '/about', '/faqs', '/library', '/es/library', '/ar/library', '/library/page/6', '/library/author/rula-diab/page/6', '/search', '/library/aba-school-readiness-arizona', '/library/aba-school-readiness-guide', '/library/autism-fall-break-routine-tips-halloween-thanksgiving', '/library/play-leisure-skills', '/library/picky-eating-autism-parent-guide', '/es/library/aba-school-readiness-arizona', '/es/library/autism-evaluation-what-to-expect', '/es/library/positive-reinforcement-techniques', '/es/library/autism-self-advocacy-skills-aba', '/ar/library/aba-school-readiness-guide', '/ar/library/first-then-cards-autism-transitions', '/ar/library/positive-reinforcement-techniques', '/library/author/rula-diab', '/es/library/author/rula-diab', '/ar/library/author/rula-diab', '/client-forms', '/employee-portal', '/careers', '/insurance', '/donate-autism-giveback', '/tour', '/schedule-consultation'];
const routes = new Set(inventory.rows.map(r=>r.route));
if (candidates.some(r=>!routes.has(r))) { await browser.close(); throw Error('Representative route is absent from actual manifest.'); }
const safePath = url => {const u=new URL(url); return {origin:u.origin,path:u.pathname};};
async function context(javaScriptEnabled=true, width=1440) {
const c=await browser.newContext({javaScriptEnabled, viewport:{width,height:width===390?844:900},deviceScaleFactor:1, serviceWorkers:'block',acceptDownloads:false});
await c.route('**/*',async route=>{
const request=route.request(), u=new URL(request.url());
if(u.origin!==origin || !['GET','HEAD'].includes(request.method())) {
evidence.blocked.push({...safePath(request.url()),method:request.method()});
if(u.hostname==='api.azinstitute4autism.com') evidence.productionAttempt=true;
return route.abort();
}
return route.continue();
});
// Block WebSockets as well; require a Playwright version supporting this API.
if(typeof c.routeWebSocket!=='function'){await c.close();throw Error('Existing Playwright must support routeWebSocket for fail-closed test isolation.');}
await c.routeWebSocket('**/*',ws=>ws.close());
return c;
}
async function inspect(page, route) {
await page.goto(origin+route,{waitUntil:'networkidle',timeout:15000});
await page.evaluate(()=>document.fonts.ready);
if(evidence.productionAttempt) throw Error('Production API attempt prevented: stop and review P9-04.');
return page.evaluate(()=>({url:location.pathname,title:document.title,h1:document.querySelector('main h1')?.textContent?.trim(),lang:document.documentElement.lang,dir:document.documentElement.dir,robots:document.querySelector('meta[name=robots]')?.getAttribute('content'),pageOverflow:document.documentElement.scrollWidth>innerWidth,clippedCandidates:[...document.querySelectorAll('main img,main table,header,footer')].filter(e=>{const r=e.getBoundingClientRect();return r.left<-.5||r.right>innerWidth+.5;}).map(e=>e.tagName),imagesMissing:[...document.images].filter(e=>e.loading!=='lazy'&&(!e.complete||!e.naturalWidth)).length}));
}
try {
const probe=await context();const page=await probe.newPage();
const first=await inspect(page,'/es/privacy-policy');
if(!first.h1?.includes('Política')||first.robots!=='noindex,nofollow')throw Error('Actual staging-page probe failed.');
await page.screenshot({path:path.join(output,'transport-probe.png'),fullPage:true});
await inspect(page,'/'); // Subsequent navigation must work before suite starts.
await probe.close();
for (const width of [1440,390]) {
const c=await context(true,width),p=await c.newPage();
// Store only error category, not arbitrary console payloads/URLs or request bodies.
p.on('pageerror',()=>evidence.runtimeErrors.push({route:safePath(p.url()).path,type:'pageerror'}));
p.on('console',message=>{if(message.type()==='error') evidence.consoleErrors.push({route:safePath(p.url()).path,type:message.text().includes('net::ERR_FAILED')?'blocked-resource-error':'other-console-error'});});
for(const route of width===1440?[...routes]:candidates){
console.log(`${width}px ${route}`);
const result=await inspect(p,route);Object.assign(result,{route,width});evidence.smoke.push(result);
if(candidates.includes(route)){
// Walk the full document to trigger lazy images; auto-opening drawers are separately reviewed.
await p.evaluate(async()=>{for(let y=0;y<document.documentElement.scrollHeight;y+=600){scrollTo({top:y,behavior:'instant'});await new Promise(r=>setTimeout(r,30));}scrollTo({top:0,behavior:'instant'});});
// Hidden carousel/lazy images may never resolve decode(). Bound readiness
// and record failures instead of hanging the entire route suite.
result.captureImages = await p.evaluate(async()=>{
for(const img of document.images) img.loading='eager';
await Promise.race([
Promise.all([...document.images].map(i=>i.decode().catch(()=>{}))),
new Promise(resolve=>setTimeout(resolve,3000)),
]);
return [...document.images].filter(i=>!i.complete||!i.naturalWidth).map(i=>i.getAttribute('src'));
});
if(evidence.productionAttempt) throw Error('Production API attempt prevented: stop and review P9-04.');
const file=`${width}-${route.replace(/[^a-z0-9-]/gi,'_')||'home'}.png`;
await p.screenshot({path:path.join(output,file),fullPage:true});evidence.screenshots.push({route,width,file,fullPage:true,visualAcceptance:'Pending'});
if(['/es/contact','/ar/library/author/rula-diab','/library'].includes(route)){
const sequence=[];for(let i=0;i<12;i++){if(evidence.productionAttempt) throw Error('Production API attempt prevented: stop and review P9-04.');await p.keyboard.press('Tab');sequence.push(await p.evaluate(()=>({tag:document.activeElement.tagName,role:document.activeElement.getAttribute('role'),name:document.activeElement.getAttribute('aria-label')||document.activeElement.textContent?.trim().slice(0,80),outline:getComputedStyle(document.activeElement).outlineStyle})));}
evidence.keyboardObservations.push({route,width,sequence,acceptance:'Manual review required'});
}
}
}
await c.close();
}
const c=await context(false,390),p=await c.newPage();
for(const route of candidates)evidence.noJS.push({route,...await inspect(p,route),acceptance:'Content presence only; manual interaction pending'});
await c.close();
if(manual){
// Review in these guarded contexts, never an ordinary unguarded browser tab.
for(const [width,js] of [[1440,true],[390,true],[390,false]]){
const review=await context(js,width),page=await review.newPage();
await inspect(page,'/es/contact');
console.log(`Manual review: ${width}px, JavaScript ${js}. Use loopback routes only; no form submission. Resume Inspector when done.`);
await page.pause();
if(evidence.productionAttempt) throw Error('Production API attempt prevented: stop and review P9-04.');
await review.close();
}
}
} catch(error) {
evidence.failure=String(error.message).replace(/https?:\/\/[^\s)]+/g,'[URL omitted]');process.exitCode=1;
} finally {
evidence.finishedAt=new Date().toISOString();await fs.writeFile(path.join(output,'results.json'),JSON.stringify(evidence,null,2)+'\n');await browser.close();
}
+137
View File
@@ -0,0 +1,137 @@
/** Bounded local follow-up. Existing Playwright only; artifacts/profile stay in /tmp. */
import fs from 'node:fs/promises';
import path from 'node:path';
import {pathToFileURL} from 'node:url';
import assert from 'node:assert/strict';
const {chromium} = await import(process.env.PLAYWRIGHT_MODULE ? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href : 'playwright');
const output = path.resolve(process.argv[2] || '');
if (!output.startsWith('/tmp/')) throw Error('Provide a fresh /tmp evidence directory');
await fs.mkdir(output);
const origin = 'http://127.0.0.1:4321';
const evidence = {startedAt: new Date().toISOString(), checks: [], blocked: [], productionAttempt: false, screenshots: [], runtimeErrors: []};
const browser = await chromium.launch({headless: true});
evidence.browser = browser.version();
async function guard(context) {
await context.route('**/*', route => {
const request = route.request(), url = new URL(request.url());
if (url.origin !== origin || !['GET', 'HEAD'].includes(request.method())) {
evidence.blocked.push({origin: url.origin, path: url.pathname, method: request.method()});
if (url.hostname === 'api.azinstitute4autism.com') evidence.productionAttempt = true;
return route.abort();
}
return route.continue();
});
await context.routeWebSocket('**/*', socket => socket.close());
}
async function context(width = 1440) {
const c = await browser.newContext({viewport: {width, height: width === 1440 ? 900 : 844}, deviceScaleFactor: 1, serviceWorkers: 'block', acceptDownloads: false});
await guard(c); return c;
}
async function go(page, route) {
await page.goto(origin + route, {waitUntil: 'networkidle'});
await page.evaluate(() => document.fonts.ready);
assert.equal(evidence.productionAttempt, false, 'Prevented production API attempt; stop');
}
async function test(name, run) {
console.log(name);
try { evidence.checks.push({name, status: 'pass', detail: await run()}); }
catch (error) { evidence.checks.push({name, status: 'fail', error: error.message}); }
if (evidence.productionAttempt) throw Error('Prevented production API attempt; stop');
}
async function capture(page, file) {
const missing = await page.evaluate(async () => {
for (const img of document.images) img.loading = 'eager';
await Promise.race([
Promise.all([...document.images].map(img => img.decode().catch(() => {}))),
new Promise(resolve => setTimeout(resolve, 3000)),
]);
return [...document.images].filter(img => !img.complete || !img.naturalWidth).map(img => img.getAttribute('src'));
});
assert.deepEqual(missing, [], 'Capture has unloaded images');
await page.evaluate(() => scrollTo({top: 0, behavior: 'instant'}));
await page.waitForTimeout(100);
await page.screenshot({path: path.join(output, file), fullPage: true});
evidence.screenshots.push(file);
}
const geometry = page => page.evaluate(() => ({width: innerWidth, documentWidth: document.documentElement.scrollWidth, dpr: devicePixelRatio, visualScale: visualViewport.scale}));
const routesOpened=new Set();
const open=async(p,r)=>{await go(p,r);routesOpened.add(r.split('#')[0]);};
try {
for(const width of [320,390,768,1440]) {
const c=await context(width),p=await c.newPage();p.on('pageerror',e=>evidence.runtimeErrors.push(e.message));
for(const route of ['/','/es']) await test(`home ${route} ${width}`,async()=>{
await open(p,route);const h=p.locator('.hsa-fsa-section');assert.equal(await h.count(),1);
assert.match(await h.locator('img').getAttribute('src'),route==='/es'?/accepted-es.png$/:/accepted.png$/);
if(route==='/es'){assert.match(await h.locator('img').getAttribute('alt'),/APROBADO/);const siblings=await h.evaluate(e=>[e.previousElementSibling.textContent,e.nextElementSibling.textContent]);assert.match(siblings[0],/ESA/);assert.match(siblings[1],/Necesita ayuda/);}
assert.equal((await geometry(p)).documentWidth,width);await capture(p,`home-${route==='/'?'en':'es'}-${width}.png`);return geometry(p);
});
await test(`privacy fragment ${width}`,async()=>{
await open(p,'/es/privacy-policy#información-que-recopilamos');const t=p.locator('#información-que-recopilamos');assert.equal(await p.locator('.source-page h2').count(),11);
assert.equal(await t.evaluate(e=>e.nextElementSibling.nextElementSibling.children.length),4);assert.ok(await t.evaluate(e=>e.getBoundingClientRect().top>=-1&&e.getBoundingClientRect().bottom<innerHeight));assert.equal((await geometry(p)).documentWidth,width);await capture(p,`privacy-${width}.png`);return {h2:11,fragment:true,position:await t.evaluate(e=>({top:e.getBoundingClientRect().top,bottom:e.getBoundingClientRect().bottom}))};
});
await test(`carousel controls keyboard ${width}`,async()=>{
await open(p,'/tour');await p.locator('[data-image-carousel][data-enhanced]').waitFor();const next=p.locator('[data-next]'),prev=p.locator('[data-previous]');
const state=async i=>{assert.equal(await p.locator('[data-slide]:visible').count(),1);assert.equal(await p.locator('[data-thumbnail][aria-pressed=true]').count(),1);assert.ok(await p.locator('[data-slide]').nth(i).isVisible());assert.equal(await p.locator('[data-thumbnail]').nth(i).getAttribute('aria-pressed'),'true');};
await state(0);await prev.click();await state(8);await next.click();await state(0);
await next.focus();await p.keyboard.press('Enter');await state(1);assert.ok(await next.evaluate(e=>e===document.activeElement));
await p.keyboard.press('Space');await state(2);await p.keyboard.press('ArrowRight');await state(3);await p.keyboard.press('ArrowLeft');await state(2);
await p.locator('[data-thumbnail]').nth(8).focus();await p.keyboard.press('Space');await state(8);assert.match(await p.locator('[data-carousel-status]').textContent(),/Photo 9 of 9: Art Gallery/);
const focus=await p.locator('[data-thumbnail]').nth(8).evaluate(e=>({outline:getComputedStyle(e).outlineWidth,color:getComputedStyle(e).outlineColor}));assert.equal(focus.outline,'3px');assert.ok(await p.locator('[data-carousel-status]').evaluate(e=>e.getBoundingClientRect().width<=1));
await prev.focus();await p.keyboard.press('Tab');assert.ok(await next.evaluate(e=>e===document.activeElement));await p.keyboard.press('Shift+Tab');assert.ok(await prev.evaluate(e=>e===document.activeElement));await p.keyboard.press('Tab');await p.keyboard.press('Tab');assert.ok(await p.locator('[data-thumbnail]').first().evaluate(e=>e===document.activeElement));await p.keyboard.press('Enter');await state(0);
assert.equal((await geometry(p)).documentWidth,width);const image=await p.locator('[data-slide]:visible img').evaluate(e=>({width:e.clientWidth,height:e.clientHeight,naturalWidth:e.naturalWidth,naturalHeight:e.naturalHeight}));assert.ok(Math.abs(image.width/image.height-image.naturalWidth/image.naturalHeight)<.02);
await capture(p,`tour-${width}.png`);
await p.getByRole('link',{name:'Schedule a Tour Today',exact:true}).click();
await p.waitForURL('**/client-consultation');
assert.equal(new URL(p.url()).pathname,'/client-consultation');
routesOpened.add('/client-consultation');
return {focus,image,wrap:true,caption:true,keyboard:'Tab Shift+Tab Enter Space ArrowLeft ArrowRight'};
});await c.close();
const n=await browser.newContext({viewport:{width,height:844},javaScriptEnabled:false,serviceWorkers:'block',acceptDownloads:false});await guard(n);
await test(`Tour no-JS ${width}`,async()=>{const p=await n.newPage();await open(p,'/tour');assert.equal(await p.locator('[data-slide]:visible').count(),9);assert.equal(await p.locator('[data-carousel-controls]:visible').count(),0);assert.equal((await geometry(p)).documentWidth,width);await capture(p,`tour-nojs-${width}.png`);return {photos:9,controlsHidden:true};});await n.close();
}
}finally{await browser.close();}
evidence.routesOpened=[...routesOpened];
// Native tab zoom, not CSS zoom, pinch emulation or a viewport-only approximation.
// This temporary extension has no network code. Website service workers remain blocked.
if (typeof chromium.launchPersistentContext === 'function' && !evidence.productionAttempt) {
await test('native browser zoom 200%', async () => {
const extension = path.join(output, 'zoom-extension'); await fs.mkdir(extension);
await fs.writeFile(path.join(extension, 'manifest.json'), JSON.stringify({manifest_version: 3, name: 'Local Phase 8 Zoom Probe', version: '1.0', permissions: ['tabs'], background: {service_worker: 'worker.js'}}));
await fs.writeFile(path.join(extension, 'worker.js'), 'chrome.runtime.onInstalled.addListener(() => {});');
const c = await chromium.launchPersistentContext(path.join(output, 'temporary-browser-profile'), {headless: true, viewport: null, serviceWorkers: 'block', acceptDownloads: false, args: ['--window-size=1440,1000', `--disable-extensions-except=${extension}`, `--load-extension=${extension}`]});
try {
await guard(c); const worker = c.serviceWorkers()[0] || await c.waitForEvent('serviceworker', {timeout: 10000}); const p = await c.newPage(); const rows = [];
for (const route of ['/tour','/es/privacy-policy']) {
await go(p, route);
const factor = await worker.evaluate(async () => {const tab = (await chrome.tabs.query({})).find(t => t.url?.startsWith('http://127.0.0.1:4321')); await chrome.tabs.setZoom(tab.id, 2); return chrome.tabs.getZoom(tab.id);});
await p.waitForTimeout(200); const g = await geometry(p); assert.equal(factor, 2); assert.equal(g.dpr, 2); assert.equal(g.width, 720); assert.equal(g.documentWidth, 720);
// Playwright fullPage clips to CSS dimensions at native tab zoom. Capture
// the actual viewport through CDP instead, with documented overlap.
await p.evaluate(async () => {
for (const img of document.images) img.loading = 'eager';
await Promise.race([Promise.all([...document.images].map(img => img.decode().catch(() => {}))), new Promise(r => setTimeout(r, 3000))]);
});
const client = await c.newCDPSession(p), frames = [];
const dimensions = await p.evaluate(() => ({height: innerHeight, total: document.documentElement.scrollHeight}));
const step = Math.floor(dimensions.height * .8);
for (let top = 0, index = 0; top < dimensions.total; top += step, index++) {
await p.evaluate(top => scrollTo({top, behavior: 'instant'}), top);
await p.waitForTimeout(80);
const file = `zoom200-${route.replaceAll('/', '_')}-${index}.png`;
const shot = await client.send('Page.captureScreenshot', {format: 'png', captureBeyondViewport: false});
await fs.writeFile(path.join(output, file), Buffer.from(shot.data, 'base64'));
frames.push({file, scrollY: await p.evaluate(() => scrollY)});
evidence.screenshots.push(file);
if (frames.at(-1).scrollY + dimensions.height >= dimensions.total) break;
}
await client.detach(); rows.push({route, zoom: factor, ...g, ...dimensions, overlap: '20%', frames});
}
return rows;
} finally {await c.close();}
});
} else evidence.checks.push({name: 'native browser zoom 200%', status: 'not-run', reason: 'No persistent-context API on supplied browser module'});
evidence.finishedAt = new Date().toISOString();
await fs.writeFile(path.join(output, 'results.json'), JSON.stringify(evidence, null, 2) + '\n');
console.log(JSON.stringify({checks: evidence.checks.length, failures: evidence.checks.filter(c => c.status !== 'pass'), productionAttempt: evidence.productionAttempt}, null, 2));
if (evidence.failure || evidence.productionAttempt || evidence.checks.some(c => c.status === 'fail')) process.exitCode = 1;
+225
View File
@@ -0,0 +1,225 @@
/** Bounded local follow-up. Existing Playwright only; artifacts/profile stay in /tmp. */
import fs from 'node:fs/promises';
import path from 'node:path';
import {pathToFileURL} from 'node:url';
import assert from 'node:assert/strict';
const {chromium} = await import(process.env.PLAYWRIGHT_MODULE ? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href : 'playwright');
const output = path.resolve(process.argv[2] || '');
if (!output.startsWith('/tmp/')) throw Error('Provide a fresh /tmp evidence directory');
await fs.mkdir(output);
const origin = 'http://127.0.0.1:4321';
const evidence = {startedAt: new Date().toISOString(), checks: [], blocked: [], productionAttempt: false, screenshots: [], runtimeErrors: []};
const browser = await chromium.launch({headless: true});
evidence.browser = browser.version();
async function guard(context) {
await context.route('**/*', route => {
const request = route.request(), url = new URL(request.url());
if (url.origin !== origin || !['GET', 'HEAD'].includes(request.method())) {
evidence.blocked.push({origin: url.origin, path: url.pathname, method: request.method()});
if (url.hostname === 'api.azinstitute4autism.com') evidence.productionAttempt = true;
return route.abort();
}
return route.continue();
});
await context.routeWebSocket('**/*', socket => socket.close());
}
async function context(width = 1440) {
const c = await browser.newContext({viewport: {width, height: width === 1440 ? 900 : 844}, deviceScaleFactor: 1, serviceWorkers: 'block', acceptDownloads: false});
await guard(c); return c;
}
async function go(page, route) {
await page.goto(origin + route, {waitUntil: 'networkidle'});
await page.evaluate(() => document.fonts.ready);
assert.equal(evidence.productionAttempt, false, 'Prevented production API attempt; stop');
}
async function test(name, run) {
console.log(name);
try { evidence.checks.push({name, status: 'pass', detail: await run()}); }
catch (error) { evidence.checks.push({name, status: 'fail', error: error.message}); }
if (evidence.productionAttempt) throw Error('Prevented production API attempt; stop');
}
async function capture(page, file) {
const missing = await page.evaluate(async () => {
for (const img of document.images) img.loading = 'eager';
await Promise.race([
Promise.all([...document.images].map(img => img.decode().catch(() => {}))),
new Promise(resolve => setTimeout(resolve, 3000)),
]);
return [...document.images].filter(img => !img.complete || !img.naturalWidth).map(img => img.getAttribute('src'));
});
assert.deepEqual(missing, [], 'Capture has unloaded images');
await page.screenshot({path: path.join(output, file), fullPage: true});
evidence.screenshots.push(file);
}
const geometry = page => page.evaluate(() => ({width: innerWidth, documentWidth: document.documentElement.scrollWidth, dpr: devicePixelRatio, visualScale: visualViewport.scale}));
const headingStyles = page => page.locator('h2.heading-size-h3').evaluateAll(elements => elements.map(e => ({tag: e.tagName, id: e.id, text: e.textContent.trim(), font: getComputedStyle(e).fontSize, line: getComputedStyle(e).lineHeight, marginTop: getComputedStyle(e).marginTop, marginBottom: getComputedStyle(e).marginBottom, rect: {width: e.getBoundingClientRect().width, height: e.getBoundingClientRect().height}})));
if (process.env.PHASE8_ZOOM_ONLY !== '1') {
try {
for (const width of [320, 390, 768, 1440]) {
const c = await context(width), p = await c.newPage();
for (const route of ['/', '/es']) await test(`home reflow ${route} ${width}`, async () => {
await go(p, route); const g = await geometry(p); assert.equal(g.width, g.documentWidth);
assert.equal(await p.locator('.director-layout').evaluate(e => getComputedStyle(e).gridTemplateColumns.split(' ').length), width <= 760 ? 1 : 2);
return g;
});
await c.close();
}
for (const width of [1440, 390]) {
const c = await context(width), p = await c.newPage();
p.on('pageerror', () => evidence.runtimeErrors.push({route: new URL(p.url()).pathname, type: 'pageerror'}));
for (const route of ['/ar/library/aba-school-readiness-guide', '/ar/library', '/ar/library/author/rula-diab']) await test(`Arabic chrome ${route} ${width}`, async () => {
await go(p, route);
assert.equal(await p.locator('html').getAttribute('dir'), 'rtl');
assert.ok(await p.getByRole('button', {name: 'الإعجاب بهذا المقال', exact: true}).count());
assert.equal(await p.getByRole('button', {name: 'Like this article', exact: true}).count(), 0);
if (await p.locator('.blog-post-footer').count()) {
assert.equal(await p.locator('.related-posts h2').innerText(), 'مقالات ذات صلة');
assert.equal(await p.locator('.post-pagination').getAttribute('aria-label'), 'التنقل بين المقالات');
}
const g = await geometry(p); assert.equal(g.width, g.documentWidth);
const labels = await p.locator('[data-like],.post-pagination,.related-post__image-link').evaluateAll(es => es.map(e => e.getAttribute('aria-label')));
await capture(p, `${width}-${route.replaceAll('/', '_')}.png`);
return {labels, ...g};
});
for (const route of ['/library/aba-therapy-dtt-net-guide', '/schedule-consultation']) await test(`semantic heading appearance ${route} ${width}`, async () => {
await go(p, route); const current = await headingStyles(p);
assert.equal(current.length, route.includes('dtt') ? 3 : 1);
// Optional before evidence enables exact computed-style/geometry comparison.
if (process.env.PHASE8_BEFORE) {
const before = JSON.parse(await fs.readFile(process.env.PHASE8_BEFORE, 'utf8')).find(r => r.route === route && r.width === width);
for (const h of current) {
const old = before.headings.find(b => b.id === h.id); assert.ok(old);
assert.deepEqual({...h, tag: old.tag}, old);
}
}
await capture(p, `${width}-${route.replaceAll('/', '_')}.png`); return current;
});
await test(`contact links and keyboard focus ${width}`, async () => {
await go(p, '/es/contact'); const links = await p.locator('.contact-page__copy a:not(.content-button),.contact-form-card__note a').evaluateAll(es => es.map(e => ({color: getComputedStyle(e).color, underline: getComputedStyle(e).textDecorationLine})));
assert.ok(links.every(l => l.color === 'rgb(37, 64, 128)' && l.underline === 'underline'));
await p.keyboard.press('Tab');
const focus = await p.locator('.skip-link').evaluate(e => ({active: document.activeElement === e, outline: getComputedStyle(e).outline, top: e.getBoundingClientRect().top, left: e.getBoundingClientRect().left}));
assert.ok(focus.active && focus.top >= 0 && focus.left >= 0); assert.match(focus.outline, /3px/);
await p.locator('.skip-link').screenshot({path: path.join(output, `skip-focus-${width}.png`)});
return {links, focus};
});
await test(`bounded contrast states ${width}`, async () => {
await go(p, '/library');
const readMore = p.locator('.text-link').first();
const normal = await readMore.evaluate(e => ({color: getComputedStyle(e).color, decoration: getComputedStyle(e).textDecorationLine}));
assert.equal(normal.color, 'rgb(37, 64, 128)'); assert.equal(normal.decoration, 'underline');
await readMore.hover();
assert.equal(await readMore.evaluate(e => getComputedStyle(e).color), 'rgb(14, 17, 51)');
const author = p.locator('.blog-card .author-byline').first(); await author.focus();
const focus = await author.evaluate(e => ({outline: getComputedStyle(e).outline, offset: getComputedStyle(e).outlineOffset, rect: {left: e.getBoundingClientRect().left, right: e.getBoundingClientRect().right}, parent: {left: e.closest('.card-body').getBoundingClientRect().left, right: e.closest('.card-body').getBoundingClientRect().right}}));
assert.match(focus.outline, /rgb\(37, 64, 128\) solid 3px/);
assert.ok(focus.rect.left - 6 > focus.parent.left && focus.rect.right + 6 < focus.parent.right);
const eyebrow = await p.locator('.eyebrow').first().evaluate(e => getComputedStyle(e).color);
assert.equal(eyebrow, 'rgb(37, 64, 128)');
await p.locator('.lang-switcher summary').click();
const current = p.locator('.lang-switcher__menu a[aria-current="page"]');
assert.equal(await current.evaluate(e => getComputedStyle(e).color), 'rgb(37, 64, 128)');
await p.keyboard.press('Escape');
await go(p, '/ar/library/aba-school-readiness-guide');
const related = p.locator('.related-post h3 a').first(); await related.hover();
assert.equal(await related.evaluate(e => getComputedStyle(e).color), 'rgb(14, 17, 51)');
return {normal, authorFocus: focus, eyebrow, hoverColor: 'rgb(14, 17, 51)'};
});
await test(`Arabic diagrams ${width}`, async () => {
await go(p, '/ar/library/aba-school-readiness-guide'); const regions = p.locator('.article-diagram-scroll'); assert.equal(await regions.count(), 3);
const rows = [];
for (let i = 0; i < 3; i++) {
const region = regions.nth(i); await region.focus();
const initial = await region.evaluate(e => ({name: e.getAttribute('aria-label'), role: e.getAttribute('role'), width: e.clientWidth, contentWidth: e.scrollWidth, scroll: e.scrollLeft, outline: getComputedStyle(e).outline, imageReady: e.querySelector('img').complete && e.querySelector('img').naturalWidth > 0}));
for (let j = 0; j < 6; j++) await p.keyboard.press('ArrowLeft');
await p.waitForTimeout(250); const end = await region.evaluate(e => e.scrollLeft);
assert.ok(initial.name && initial.imageReady); assert.match(initial.outline, /3px/);
if (width === 390) assert.notEqual(initial.scroll, end);
await region.screenshot({path: path.join(output, `diagram-${width}-${i}.png`)});
rows.push({...initial, end});
}
const g = await geometry(p); assert.equal(g.width, g.documentWidth); return rows;
});
await c.close();
}
// Compare native browser focus boundaries with a minimal modal control fixture.
for (const width of [1440, 390]) for (const close of ['Escape', 'button']) {
await test(`native service dialog ${width} ${close}`, async () => {
const c = await context(width), p = await c.newPage();
try {
await go(p, '/aba-therapy'); const brand = p.locator('.brand'); await brand.focus();
const dialog = p.locator('[data-service-inquiry-drawer]');
await p.waitForFunction(() => document.querySelector('[data-service-inquiry-drawer]')?.open, {}, {timeout: 14000});
await p.waitForTimeout(600);
const entry = await p.evaluate(() => ({tag: document.activeElement.tagName, inside: !!document.activeElement.closest('dialog')})); assert.ok(entry.inside);
const sequence = [];
for (const key of ['Tab', 'Shift+Tab']) for (let i = 0; i < 20; i++) {
await p.keyboard.press(key);
const step = await dialog.evaluate(e => ({tag: document.activeElement.tagName, id: document.activeElement.id, inside: e.contains(document.activeElement), documentFocus: document.hasFocus()}));
assert.ok(step.inside || step.tag === 'BODY', 'Background control received focus'); sequence.push({key, ...step});
}
await brand.evaluate(e => e.focus());
assert.equal(await brand.evaluate(e => e === document.activeElement), false, 'Modal background became focusable');
if (close === 'Escape') await p.keyboard.press('Escape'); else {await dialog.locator('[data-service-inquiry-close]').focus(); await p.keyboard.press('Enter');}
await p.waitForTimeout(600); assert.equal(await dialog.evaluate(e => e.open), false); assert.equal(await brand.evaluate(e => e === document.activeElement), true);
return {entry, sequence, restored: true, backgroundInert: true};
} finally {await c.close();}
});
}
await test('minimal native modal boundary comparison', async () => {
const c = await context(), p = await c.newPage();
try {
await c.route(origin + '/__phase8_native_modal', r => r.fulfill({contentType: 'text/html', body: '<!doctype html><html lang="en"><button id="outside">Outside</button><dialog><button>First</button><input aria-label="Fixture"><button>Last</button></dialog><script>document.querySelector("dialog").showModal()</script></html>'}));
await go(p, '/__phase8_native_modal'); const sequence = [];
for (const key of ['Tab', 'Shift+Tab']) for (let i = 0; i < 8; i++) {await p.keyboard.press(key);sequence.push({key,...await p.evaluate(() => ({tag: document.activeElement.tagName, inside: !!document.activeElement.closest('dialog'), documentFocus: document.hasFocus()}))});}
assert.ok(sequence.every(s => s.inside || s.tag === 'BODY')); return sequence;
} finally {await c.close();}
});
} catch (error) {
evidence.failure = error.message;
} finally {await browser.close();}
} else await browser.close();
// Native tab zoom, not CSS zoom, pinch emulation or a viewport-only approximation.
// This temporary extension has no network code. Website service workers remain blocked.
if (typeof chromium.launchPersistentContext === 'function' && !evidence.productionAttempt) {
await test('native browser zoom 200%', async () => {
const extension = path.join(output, 'zoom-extension'); await fs.mkdir(extension);
await fs.writeFile(path.join(extension, 'manifest.json'), JSON.stringify({manifest_version: 3, name: 'Local Phase 8 Zoom Probe', version: '1.0', permissions: ['tabs'], background: {service_worker: 'worker.js'}}));
await fs.writeFile(path.join(extension, 'worker.js'), 'chrome.runtime.onInstalled.addListener(() => {});');
const c = await chromium.launchPersistentContext(path.join(output, 'temporary-browser-profile'), {headless: true, viewport: null, serviceWorkers: 'block', acceptDownloads: false, args: ['--window-size=1440,1000', `--disable-extensions-except=${extension}`, `--load-extension=${extension}`]});
try {
await guard(c); const worker = c.serviceWorkers()[0] || await c.waitForEvent('serviceworker', {timeout: 10000}); const p = await c.newPage(); const rows = [];
for (const route of ['/', '/es', '/es/contact', '/es/client-consultation', '/ar/library/aba-school-readiness-guide', '/library', '/schedule-consultation']) {
await go(p, route);
const factor = await worker.evaluate(async () => {const tab = (await chrome.tabs.query({})).find(t => t.url?.startsWith('http://127.0.0.1:4321')); await chrome.tabs.setZoom(tab.id, 2); return chrome.tabs.getZoom(tab.id);});
await p.waitForTimeout(200); const g = await geometry(p); assert.equal(factor, 2); assert.equal(g.dpr, 2); assert.equal(g.width, 720); assert.equal(g.documentWidth, 720);
// Playwright fullPage clips to CSS dimensions at native tab zoom. Capture
// the actual viewport through CDP instead, with documented overlap.
await p.evaluate(async () => {
for (const img of document.images) img.loading = 'eager';
await Promise.race([Promise.all([...document.images].map(img => img.decode().catch(() => {}))), new Promise(r => setTimeout(r, 3000))]);
});
const client = await c.newCDPSession(p), frames = [];
const dimensions = await p.evaluate(() => ({height: innerHeight, total: document.documentElement.scrollHeight}));
const step = Math.floor(dimensions.height * .8);
for (let top = 0, index = 0; top < dimensions.total; top += step, index++) {
await p.evaluate(top => scrollTo({top, behavior: 'instant'}), top);
await p.waitForTimeout(80);
const file = `zoom200-${route.replaceAll('/', '_')}-${index}.png`;
const shot = await client.send('Page.captureScreenshot', {format: 'png', captureBeyondViewport: false});
await fs.writeFile(path.join(output, file), Buffer.from(shot.data, 'base64'));
frames.push({file, scrollY: await p.evaluate(() => scrollY)});
evidence.screenshots.push(file);
if (frames.at(-1).scrollY + dimensions.height >= dimensions.total) break;
}
await client.detach(); rows.push({route, zoom: factor, ...g, ...dimensions, overlap: '20%', frames});
}
return rows;
} finally {await c.close();}
});
} else evidence.checks.push({name: 'native browser zoom 200%', status: 'not-run', reason: 'No persistent-context API on supplied browser module'});
evidence.finishedAt = new Date().toISOString();
await fs.writeFile(path.join(output, 'results.json'), JSON.stringify(evidence, null, 2) + '\n');
console.log(JSON.stringify({checks: evidence.checks.length, failures: evidence.checks.filter(c => c.status !== 'pass'), productionAttempt: evidence.productionAttempt}, null, 2));
if (evidence.failure || evidence.productionAttempt || evidence.checks.some(c => c.status === 'fail')) process.exitCode = 1;
+67
View File
@@ -0,0 +1,67 @@
/** Guarded browser interactions; synthetic form fixture intercepts every submission. */
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
import path from 'node:path';import {pathToFileURL} from 'node:url';
const {chromium}=await import(process.env.PLAYWRIGHT_MODULE?pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href:'playwright');
const output=path.resolve(process.argv[2]||'');if(!output.startsWith('/tmp/'))throw Error('Provide a fresh /tmp evidence directory.');await fs.mkdir(output);
const browser=await chromium.launch({headless:true}),origin='http://127.0.0.1:4321';
const result={browser:browser.version(),startedAt:new Date().toISOString(),checks:[],blocked:[],productionAttempt:false};
async function context(width=390,js=true){const c=await browser.newContext({viewport:{width,height:width===390?844:900},deviceScaleFactor:1,javaScriptEnabled:js,serviceWorkers:'block',acceptDownloads:false});await c.route('**/*',r=>{const q=r.request(),u=new URL(q.url());if(u.origin!==origin||!['GET','HEAD'].includes(q.method())){result.blocked.push({origin:u.origin,path:u.pathname,method:q.method()});if(u.hostname==='api.azinstitute4autism.com')result.productionAttempt=true;return r.abort();}return r.continue();});await c.routeWebSocket('**/*',s=>s.close());return c;}
async function go(p,route){await p.goto(origin+route,{waitUntil:'networkidle'});assert.equal(result.productionAttempt,false,'Production attempt prevented; stop.');}
async function test(name,fn){console.log(name);try{const detail=await fn();result.checks.push({name,status:detail?.review?'review':'pass',detail});}catch(e){result.checks.push({name,status:'fail',error:String(e.message).slice(0,1300)});}if(result.productionAttempt)throw Error('Production API attempt: stop');}
try{
await test('home director reflows without page overflow', async () => {
const observations = [];
for (const width of [320, 390, 768, 1440]) {
const c = await context(width), p = await c.newPage();
for (const route of ['/', '/es']) {
await go(p, route);
const layout = await p.locator('.director-layout').evaluate(e => ({
columns: getComputedStyle(e).gridTemplateColumns.split(' ').length,
pageWidth: document.documentElement.scrollWidth,
viewport: innerWidth,
clipped: [...e.children].some(child => {
const r = child.getBoundingClientRect();
return r.left < 0 || r.right > innerWidth;
}),
}));
assert.equal(layout.columns, width <= 760 ? 1 : 2);
assert.equal(layout.pageWidth, layout.viewport);
assert.equal(layout.clipped, false);
await p.locator('.director-section').screenshot({path: `${output}/director-${route === '/' ? 'en' : 'es'}-${width}.png`});
observations.push({route, width, ...layout});
}
await c.close();
}
return observations;
});
for(const width of [1440,390]){
const c=await context(width),p=await c.newPage();p.setDefaultTimeout(6000);
for(const route of ['/es/contact','/ar/library','/library']){
await test(`skip link ${route} ${width}`,async()=>{await go(p,route);await p.keyboard.press('Tab');assert.equal(await p.locator('.skip-link').evaluate(e=>e===document.activeElement),true);await p.keyboard.press('Enter');assert.equal(new URL(p.url()).hash,'#main');return await p.evaluate(()=>({focused:document.activeElement.tagName,mainTop:document.getElementById('main').getBoundingClientRect().top}));});
await test(`language menu ${route} ${width}`,async()=>{await go(p,route);const summary=p.locator('.lang-switcher summary');await summary.focus();await p.keyboard.press('Enter');assert.equal(await p.locator('.lang-switcher details').evaluate(e=>e.open),true);const links=await p.locator('.lang-switcher__menu a').evaluateAll(es=>es.map(e=>({href:e.getAttribute('href'),text:e.textContent})));assert.ok(links.every(e=>e.href!=='/ar'));await p.keyboard.press('Tab');await p.keyboard.press('Escape');assert.equal(await summary.evaluate(e=>document.activeElement===e&& !e.parentElement.open),true);return links;});
if(width===390)await test(`mobile menu ${route}`,async()=>{await go(p,route);const summary=p.locator('.mobile-nav > summary');await summary.focus();await p.keyboard.press('Enter');assert.equal(await p.locator('.mobile-nav').evaluate(e=>e.open),true);await p.keyboard.press('Tab');assert.equal(await p.locator('.mobile-nav').evaluate(e=>e.contains(document.activeElement)),true);await p.keyboard.press('Escape');assert.equal(await summary.evaluate(e=>e===document.activeElement&&!e.parentElement.open),true);return {outline:await summary.evaluate(e=>getComputedStyle(e).outline)};});
}
if(width===1440) await test('desktop submenu keyboard',async()=>{await go(p,'/');const item=p.locator('.header-inner > .main-nav li').filter({has:p.locator('ul.submenu')}).first();const a=item.locator(':scope > a');await a.focus();assert.equal(await item.locator('ul.submenu').isVisible(),true);await p.keyboard.press('Tab');assert.equal(await item.locator('ul.submenu').evaluate(e=>e.contains(document.activeElement)),true);});
for(const route of ['/library/aba-school-readiness-arizona','/es/library/aba-school-readiness-arizona','/ar/library/aba-school-readiness-guide','/faqs']){
await test(`FAQ keyboard ${route} ${width}`,async()=>{await go(p,route);const detail=p.locator('main details.faq').first(),summary=detail.locator('summary');const before=await detail.evaluate(e=>e.open);await summary.focus();await p.keyboard.press('Enter');await p.waitForFunction(({before})=>document.querySelector('main details.faq').open===!before,{before});assert.equal(await detail.evaluate(e=>e.open),!before);await p.keyboard.press('Space');await p.waitForFunction(({before})=>document.querySelector('main details.faq').open===before,{before});assert.equal(await detail.evaluate(e=>e.open),before);return {count:await p.locator('main details.faq').count(),focus:await summary.evaluate(e=>e===document.activeElement)};});
}
for(const [route,query,locale] of [['/library','meltdowns','en'],['/es/library','refuerzo','es'],['/ar/library','التعزيز','ar']]){
await test(`search ${locale} ${width}`,async()=>{await go(p,route);const input=p.locator('[data-search-input]');await input.fill(query);await input.press('Enter');await p.locator('.library-search-result').first().waitFor();const count=await p.locator('.library-search-result').count();assert.ok(count>0);assert.ok((await p.locator('.library-search-result').evaluateAll(es=>es.map(e=>e.lang))).every(l=>l===locale));await input.fill('zzzzphase8nomatch');await input.press('Enter');await p.waitForFunction(()=>document.querySelectorAll('.library-search-result').length===0);const status=await p.locator('[data-search-status]').innerText();await p.locator('[data-search-clear]').focus();await p.keyboard.press('Enter');assert.equal(await input.inputValue(),'');return {count,noResults:status,live:await p.locator('[data-search-status]').getAttribute('aria-live')};});
}
await test(`Arabic diagram scrolling ${width}`,async()=>{await go(p,'/ar/library/aba-school-readiness-guide');const regions=p.locator('.article-diagram-scroll'),observations=[];assert.equal(await regions.count(),3);for(let i=0;i<3;i++){const r=regions.nth(i);await r.focus();const before=await r.evaluate(e=>({left:e.scrollLeft,width:e.clientWidth,scrollWidth:e.scrollWidth,name:e.getAttribute('aria-label'),outline:getComputedStyle(e).outline}));for(let n=0;n<5;n++)await p.keyboard.press('ArrowLeft');await p.waitForTimeout(250);const after=await r.evaluate(e=>e.scrollLeft);if(width===390){assert.ok(before.scrollWidth>before.width);assert.notEqual(before.left,after);}assert.ok(before.name);await r.screenshot({path:`${output}/diagram-${width}-${i}.png`});observations.push({...before,after});}assert.equal(await p.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);return observations;});
await c.close();
}
const c=await context(390),p=await c.newPage();p.setDefaultTimeout(6000);
await test('service drawer delayed opening and keyboard containment',async()=>{await go(p,'/aba-therapy');const prior=p.locator('.brand');await prior.focus();const dialog=p.locator('[data-service-inquiry-drawer]');await p.waitForFunction(()=>document.querySelector('[data-service-inquiry-drawer]')?.open,{},{timeout:14000});await p.waitForTimeout(700);const sequence=[];const start=await p.evaluate(()=>({tag:document.activeElement.tagName,inDialog:!!document.activeElement.closest('dialog')}));assert.ok(start.inDialog);for(let i=0;i<18;i++){await p.keyboard.press('Tab');const state=await dialog.evaluate(e=>({tag:document.activeElement.tagName,inside:e.contains(document.activeElement),html:document.activeElement.outerHTML.slice(0,220)}));sequence.push({tab:i,...state});assert.ok(state.inside||state.tag==='BODY');}await p.keyboard.press('Escape');await p.waitForTimeout(600);assert.equal(await dialog.evaluate(e=>e.open),false);return {start,sequence,restored:await prior.evaluate(e=>e===document.activeElement),review:sequence.some(s=>s.tag==='BODY')?'Native browser focus boundary reached; background controls stayed inert, but visible browser-chrome/AT acceptance remains pending':undefined};});
await test('page six boundaries',async()=>{const rows=[];for(const route of ['/library/page/6','/library/author/rula-diab/page/6']){await go(p,route);const cards=p.locator('article.blog-card');assert.equal(await cards.count(),5);const links=await p.locator('.library-pagination a').evaluateAll(es=>es.map(e=>e.getAttribute('href')));assert.ok(links.some(h=>h.endsWith('/5')));assert.ok(!links.some(h=>h.endsWith('/7')));rows.push({route,cards:5,links});}return rows;});
await test('image link and browser Back',async()=>{await go(p,'/library/play-leisure-skills');const link=p.locator('main a[href$=".png"]').first();const href=await link.getAttribute('href');await link.focus();await p.keyboard.press('Enter');await p.waitForURL(origin+href);await p.goBack({waitUntil:'networkidle'});assert.equal(new URL(p.url()).pathname,'/library/play-leisure-skills');return {href};});
await test('picky eating section fragments',async()=>{await go(p,'/library/picky-eating-autism-parent-guide');const links=await p.locator('main a[href^="#"]').evaluateAll(es=>es.map(e=>e.getAttribute('href')));assert.equal(links.length,4);for(const href of links){await p.locator(`main a[href="${href}"]`).click();assert.equal(new URL(p.url()).hash,href);assert.equal(await p.locator(`[id="${href.slice(1)}"]`).count(),1);}return links;});
await test('disabled Spanish form and intercepted submission fixture',async()=>{await go(p,'/es/contact');const form=p.locator('main form').first();const html=await form.evaluate(e=>e.outerHTML);assert.equal(await form.getAttribute('action'),'');assert.equal(await form.locator('[type=submit]').isDisabled(),true);const fixture=await context(390);let prevented=false;await fixture.route(origin+'/__phase8_form_fixture',r=>{if(r.request().method()!=='GET'){prevented=true;return r.abort();}return r.fulfill({contentType:'text/html',body:'<!doctype html><html lang="es"><title>Isolated form fixture</title><body>'+html+'</body></html>'});});const page=await fixture.newPage();await page.goto(origin+'/__phase8_form_fixture');await page.evaluate(()=>HTMLFormElement.prototype.submit.call(document.querySelector('form')));await page.waitForTimeout(500);await fixture.close();assert.equal(prevented,true);return {programmaticPostPrevented:true,meaning:'Disabled submit is not complete form isolation; P9-03 remains pending. No field data supplied.'};});
await c.close();
const nojs=await context(390,false),n=await nojs.newPage();n.setDefaultTimeout(6000);
for(const route of ['/es/contact','/library','/ar/library/aba-school-readiness-guide'])await test(`no-JS keyboard ${route}`,async()=>{await go(n,route);const menu=n.locator('.mobile-nav > summary');await menu.focus();await n.keyboard.press('Enter');assert.equal(await n.locator('.mobile-nav').evaluate(e=>e.open),true);await n.keyboard.press('Enter');const language=n.locator('.lang-switcher summary');await language.focus();await n.keyboard.press('Enter');assert.equal(await n.locator('.lang-switcher__menu').isVisible(),true);if(route.includes('school-readiness')){const d=n.locator('details.faq').first();const before=await d.evaluate(e=>e.open);await d.locator('summary').focus();await n.keyboard.press('Enter');assert.equal(await d.evaluate(e=>e.open),!before);}return {h1:await n.locator('main h1').innerText(),searchNotice:await n.locator('.library-search__noscript').count()};});
await nojs.close();
}finally{result.finishedAt=new Date().toISOString();await fs.writeFile(output+'/results.json',JSON.stringify(result,null,2));await browser.close();console.log(JSON.stringify({checks:result.checks.length,reviews:result.checks.filter(x=>x.status==='review'),failures:result.checks.filter(x=>x.status==='fail'),blocked:result.blocked,productionAttempt:result.productionAttempt},null,2));}
if (result.productionAttempt || result.checks.some(check => check.status === "fail")) process.exitCode = 1;
+34
View File
@@ -0,0 +1,34 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
const read=p=>fs.readFileSync(new URL('../src/'+p,import.meta.url),'utf8');
const tokens=Object.fromEntries([...read('styles/variables.css').matchAll(/(--[\w-]+):\s*(#[\da-f]{6})/gi)].map(m=>[m[1],m[2]]));
const luminance=hex=>hex.slice(1).match(/../g).map(h=>parseInt(h,16)/255).map(c=>c<=.04045?c/12.92:((c+.055)/1.055)**2.4).reduce((s,c,i)=>s+c*[.2126,.7152,.0722][i],0);
const ratio=(a,b)=>(Math.max(luminance(a),luminance(b))+.05)/(Math.min(luminance(a),luminance(b))+.05);
function colorRule(source,selector){const start=source.indexOf(selector);assert.ok(start>=0,selector);const body=source.slice(source.indexOf('{',start)+1,source.indexOf('}',start));const token=body.match(/color:\s*var\((--[\w-]+)\)/)?.[1];assert.ok(tokens[token],selector);return {body,color:tokens[token]};}
function textContrast(color){for(const bg of ['#ffffff',tokens['--color-tint']])assert.ok(ratio(color,bg)>=4.5,`${color} on ${bg}`);}
test('inline prose links remain distinguishable and meet normal-text contrast in resting and interactive states',()=>{const css=read('styles/blog.css');const normal=colorRule(css,'.prose :is(p, li, td) a:not(.content-button) {');textContrast(normal.color);assert.match(normal.body,/text-decoration:\s*underline/);textContrast(colorRule(css,'.prose :is(p, li, td) a:not(.content-button):is(:hover, :focus-visible)').color);});
test('source FAQ answer links meet normal-text contrast and retain non-color identification',()=>{const css=read('components/FAQAccordion.astro');const normal=colorRule(css,'.faq-accordion--source .faq-answer__inner :global(a) {');textContrast(normal.color);assert.match(normal.body,/text-decoration:\s*underline/);textContrast(colorRule(css,'.faq-accordion--source .faq-answer__inner :global(a:focus-visible)').color);});
test('skip and contact/form instructions retain readable link contrast',()=>{
for(const [file,selector] of [
['styles/layout.css','.skip-link, .skip-link:visited'],
['styles/forms.css','.form-note a, .service-inquiry-form__footer a'],
['components/pages/ContactPage.astro','.contact-page__copy :global(a:not(.content-button))'],
['components/ContactForm.astro','.contact-form-card__note :global(a)'],
]){const rule=colorRule(read(file),selector);textContrast(rule.color);assert.match(rule.body,/text-decoration:\s*underline/);}
});
test('authored navigation, byline and diagram focus outlines contrast with their light surfaces',()=>{for(const file of ['components/MainNav.astro','components/MobileNav.astro','components/LanguageSwitcher.astro','layouts/BlogPostLayout.astro','styles/blog.css']){const css=read(file);const outlines=[...css.matchAll(/outline:\s*3px solid var\((--[\w-]+)\)/g)];assert.ok(outlines.length,file);for(const [,token]of outlines)for(const bg of ['#ffffff',tokens['--color-tint']])assert.ok(ratio(tokens[token],bg)>=3,file);}});
test('article cards, related links and light-surface navigation retain readable contrast across states', () => {
for (const [file, selector] of [
['styles/components.css', '.text-link, .text-link:visited'],
['styles/components.css', '.text-link:hover, .text-link:active'],
['styles/typography.css', '.eyebrow {'],
['components/MainNav.astro', '.main-nav a:hover'],
['components/LanguageSwitcher.astro', '.lang-switcher__menu a[aria-current="page"]'],
['components/BlogPostFooter.astro', '.post-pagination a:hover'],
]) textContrast(colorRule(read(file), selector).color);
const card = read('components/BlogCard.astro');
const token = card.match(/outline: 3px solid var\((--[\w-]+)\)/)?.[1];
assert.ok(ratio(tokens[token], '#ffffff') >= 3);
});
+106
View File
@@ -0,0 +1,106 @@
/** Environment probe only: real staging pages, loopback preview, no external egress. */
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import http from 'node:http';
import net from 'node:net';
import path from 'node:path';
import {spawn} from 'node:child_process';
import {fileURLToPath, pathToFileURL} from 'node:url';
import {setTimeout as delay} from 'node:timers/promises';
const root = fileURLToPath(new URL('../', import.meta.url));
const moduleName = process.env.PLAYWRIGHT_MODULE
? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href : 'playwright';
let preview, browser;
let previewError;
const results = {startedAt: new Date().toISOString(), pages: [], blocked: [], status: 'failed'};
const output = await fs.mkdtemp('/tmp/aia-playwright-smoke-');
const stop = signal => { preview?.kill('SIGTERM'); process.exit(signal === 'SIGINT' ? 130 : 143); };
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
try {
for (const route of ['es/privacy-policy', 'library']) {
const html = await fs.readFile(path.join(root, 'dist', route, 'index.html'), 'utf8');
assert.match(html, /name="robots" content="noindex,nofollow"/,
'Run a normal staging build before the browser probe.');
}
const {chromium} = await import(moduleName);
// Do not add flags to bypass the outer environment's security restrictions.
browser = await chromium.launch({headless: true});
results.browser = browser.version();
const reserve = net.createServer();
await new Promise((resolve, reject) => {
reserve.once('error', reject);
reserve.listen(0, '127.0.0.1', resolve);
});
const port = reserve.address().port;
await new Promise(resolve => reserve.close(resolve));
const origin = `http://127.0.0.1:${port}`;
results.origin = origin;
preview = spawn(process.execPath, [
'node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port),
], {cwd: root, env: {...process.env, ASTRO_TELEMETRY_DISABLED: '1'}, stdio: 'ignore'});
preview.once('error', error => { previewError = error; });
const ready = () => new Promise(resolve => {
const request = http.get(origin + '/es/privacy-policy', response => {
response.resume(); resolve(response.statusCode === 200);
});
request.setTimeout(500, () => request.destroy());
request.on('error', () => resolve(false));
});
let reachable = false;
for (let i = 0; i < 60; i++) {
if (previewError) throw previewError;
if (preview.exitCode !== null) throw Error('Astro preview exited before the probe.');
if (await ready()) { reachable = true; break; }
await delay(250);
}
assert.ok(reachable, 'Astro loopback preview was not reachable in this execution.');
const context = await browser.newContext({
viewport: {width: 1440, height: 900}, deviceScaleFactor: 1,
serviceWorkers: 'block', acceptDownloads: false,
});
assert.equal(typeof context.routeWebSocket, 'function', 'Playwright lacks WebSocket isolation.');
await context.routeWebSocket('**/*', socket => socket.close());
await context.route('**/*', route => {
const request = route.request(), url = new URL(request.url());
if (url.origin !== origin || !['GET', 'HEAD'].includes(request.method())) {
results.blocked.push({origin: url.origin, path: url.pathname, method: request.method()});
return route.abort();
}
return route.continue();
});
const page = await context.newPage();
for (const route of ['/es/privacy-policy', '/library']) {
const response = await page.goto(origin + route, {waitUntil: 'networkidle', timeout: 15000});
assert.equal(response.status(), 200);
assert.equal(new URL(page.url()).origin, origin);
assert.equal(await page.locator('meta[name="robots"]').getAttribute('content'), 'noindex,nofollow');
assert.equal(await page.locator('main h1').count(), 1);
assert.ok(await page.locator('main h1').isVisible());
const title = await page.title(), h1 = await page.locator('main h1').innerText();
assert.ok(title.trim() && h1.trim());
if (route.startsWith('/es/')) assert.match(h1, /Política/);
assert.equal(results.blocked.length, 0, 'Unexpected request prevented; inspect evidence before proceeding.');
await page.evaluate(() => document.fonts.ready);
await page.screenshot({path: path.join(output, route.startsWith('/es/') ? 'privacy.png' : 'library.png'), fullPage: true});
results.pages.push({route, title, h1});
}
assert.equal(results.blocked.length, 0, 'Unexpected request prevented; inspect evidence before proceeding.');
results.status = 'passed';
} catch (error) {
results.error = String(error.message);
process.exitCode = 1;
} finally {
await browser?.close();
if (preview && preview.exitCode === null) {
preview.kill('SIGTERM');
const exited = new Promise(resolve => preview.once('exit', resolve));
await Promise.race([exited, delay(3000)]);
if (preview.exitCode === null) preview.kill('SIGKILL');
}
results.finishedAt = new Date().toISOString();
await fs.writeFile(path.join(output, 'result.json'), JSON.stringify(results, null, 2) + '\n');
console.log(JSON.stringify({...results, output}, null, 2));
}
+10 -5
View File
@@ -150,7 +150,8 @@ await build({root:${JSON.stringify(temporary)},configFile:${JSON.stringify(path.
// through the actual renderer, in the existing isolated override build.
for (const lang of ['en', 'es']) {
const target = path.join(temporary, 'src/content/pages', lang, 'index.md');
const { data, content } = matter(await readFile(target, 'utf8'));
// gray-matter caches parsed objects; never mutate the manifest's source data.
const { data, content } = structuredClone(matter(await readFile(target, 'utf8')));
delete data.home.hsaFsa;
delete data.home.logos;
data.home.process.steps.forEach((step) => delete step.href);
@@ -622,17 +623,17 @@ test('Phase 5B corrects ABA-specific ages and audiences without rewriting histor
assert.equal(page('/es')('head title').text(), 'Terapia ABA cerca de mí | Arizona Institute for Autism | Scottsdale');
});
test('Phase 5B preserves section order, English-only HSA/FSA and the final source logo row', () => {
test('Phase 8 preserves homepage section order, localized HSA/FSA and the final source logo row', () => {
for (const mode of ['staging', 'indexing']) {
for (const route of ['/', '/es']) {
const $ = page(route, mode);
const home = manifest.find((item) => item.route === route).entry.data.home;
const expected = [home.servicesIntro.servicesHeading, home.servicesIntro.commitmentsHeading,
home.benefits.heading, home.skills.heading, home.insurance.heading, home.esa.heading,
...(route === '/' ? [home.hsaFsa.heading] : []), home.financialHelp.heading,
home.hsaFsa.heading, home.financialHelp.heading,
home.process.heading, home.director.heading, home.testimonials.heading];
assert.deepEqual($('main h2').map((_, el) => $(el).text()).get(), expected);
assert.equal($('.hsa-fsa-section').length, route === '/' ? 1 : 0);
assert.equal($('.hsa-fsa-section').length, 1);
assert.deepEqual($('.home-logos img').map((_, el) => $(el).attr('src')).get(), ['/assets/images/logo-BACB.png', '/assets/images/casp-member-logo.webp']);
assert.equal($('.testimonial-section').nextAll(':not(script):not(style)').first().hasClass('home-logos'), true);
if (route === '/') {
@@ -642,7 +643,11 @@ test('Phase 5B preserves section order, English-only HSA/FSA and the final sourc
assert.equal($('.hsa-fsa-section img').attr('src'), '/assets/images/hsa-fsa-accepted.png');
assert.equal($('.hsa-fsa-section img').attr('alt'), 'hsa-fsa-accepted');
assert.equal($('.hsa-fsa-section').find('a, form, script, iframe').length, 0);
} else assert.doesNotMatch($('main').text(), /Health Savings|HSA|FSA/);
} else {
assert.equal($('.hsa-fsa-section img').attr('src'), '/assets/images/hsa-fsa-accepted-es.png');
assert.equal(text($('.hsa-fsa-section p').text()), home.hsaFsa.body);
assert.doesNotMatch($('.hsa-fsa-section').text(), /Health Savings/);
}
assert.deepEqual(jsonLd($).map((item) => item['@type']), ['MedicalOrganization']);
}
}