fix(a11y): reconcile responsive layouts and multilingual UI
This commit is contained in:
59 files changed
+27178
-187
No files matched your search
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user