Files

93 lines
7.3 KiB
JavaScript

import {browser} from './browser.mjs';
import {mkdir,writeFile,readFile} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
import assert from 'node:assert/strict';
const out=new URL('../evidence/',import.meta.url), downloads=new URL('downloads/',out);
await mkdir(downloads,{recursive:true});
const report={observedAt:new Date().toISOString(),checks:[],renderedContrast:[]};
const b=await browser();
const url=new URL('../index.html',import.meta.url).href;
const change=async(id,value)=>b.evaluate(`(()=>{const e=document.getElementById(${JSON.stringify(id)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}))})()`);
const mode=async value=>b.evaluate(`document.querySelector('[name=mode][value=${value}]').click()`);
const screenshot=async(section,name,width=1440)=>{
await b.viewport(width,1100);
await b.evaluate(`document.getElementById('${section}').scrollIntoView();new Promise(requestAnimationFrame)`);
await b.screenshot(new URL(name,out));
};
try{
await b.viewport(1440,1100);await b.navigate(url);
// Computed foreground/background values on rendered text, not just token formulas.
for(const p of await b.evaluate('Brand.palettes.map(p=>p.id)')) for(const m of ['light','dim','dark']){
await change('palette',p);await mode(m);
const result=await b.evaluate(`(()=>{
const hex=c=>{const n=c.match(/[\\d.]+/g);return '#'+n.slice(0,3).map(v=>Math.round(+v).toString(16).padStart(2,'0')).join('')};
const bg=e=>{for(let n=e;n;n=n.parentElement){const c=getComputedStyle(n).backgroundColor;if(c!=='rgba(0, 0, 0, 0)'&&c!=='transparent')return hex(c)}return '#ffffff'};
const walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);let n, checks=[];
while(n=walker.nextNode()){
const e=n.parentElement;if(!n.textContent.trim()||e.closest('script,style,noscript,.sr-only,button:disabled,option'))continue;
const rect=e.getBoundingClientRect();if(!rect.width||!rect.height)continue;
const style=getComputedStyle(e);if(style.visibility==='hidden'||style.display==='none')continue;
const fg=hex(style.color),back=bg(e),ratio=Brand.contrast(fg,back);
const large=parseFloat(style.fontSize)>=24||(parseFloat(style.fontSize)>=18.67 && parseFloat(style.fontWeight)>=700);
checks.push({text:n.textContent.trim().slice(0,65),fg,bg:back,ratio,minimum:large?3:4.5});
}
for(const e of document.querySelectorAll('input:not([type=radio]),textarea,select')){
const style=getComputedStyle(e),fg=hex(style.color),back=bg(e);checks.push({text:e.id,fg,bg:back,ratio:Brand.contrast(fg,back),minimum:4.5});
}
return {count:checks.length,min:Math.min(...checks.map(x=>x.ratio)),failures:checks.filter(x=>x.ratio<x.minimum)};
})()`);
report.renderedContrast.push({palette:p,mode:m,...result});
assert.equal(result.failures.length,0,JSON.stringify({p,m,...result}));
}
report.checks.push({name:'Computed rendered text contrast across 30 combinations',result:'pass'});
await change('palette','harbor');await mode('light');
for(const section of ['identity','typography','color','components','review'])await screenshot(section,`section-${section}-light.png`);
await screenshot('color','section-color-ultrawide.png',3440);
await screenshot('identity','section-identity-mobile.png',390);
await screenshot('components','section-components-mobile.png',320);
await mode('dark');await screenshot('color','section-color-dark.png');
await mode('light');await b.viewport(1440,768);
await b.evaluate('document.getElementById("reset").focus()');await b.key('Tab','Tab',8);
const focus=await b.evaluate(`(()=>{const e=document.activeElement,r=e.getBoundingClientRect();return {text:e.textContent,top:r.top,bottom:r.bottom,viewport:innerHeight,style:getComputedStyle(e).outlineStyle}})()`);
assert(focus.top>=0&&focus.bottom<=focus.viewport,JSON.stringify(focus));
await b.screenshot(new URL('focus-short-desktop.png',out));
report.checks.push({name:'Sidebar keyboard focus visible at 768 px viewport height',result:'pass',focus});
const ax=await b.call('Accessibility.getFullAXTree');
const missingNames=ax.nodes.filter(n=>!n.ignored&&['button','combobox','textbox','radio','link'].includes(n.role?.value)&&!n.name?.value);
assert.equal(missingNames.length,0);
report.checks.push({name:'Chromium accessibility tree gives interactive elements accessible names',result:'pass',namedControls:ax.nodes.filter(n=>!n.ignored&&['button','combobox','textbox','radio','link'].includes(n.role?.value)).length});
await b.call('Browser.setDownloadBehavior',{behavior:'allow',downloadPath:fileURLToPath(downloads)});
await b.evaluate('document.getElementById("review-notes").value="Prefer Relay. Please refine the small-size gaps.";document.getElementById("download-review").click();document.getElementById("export").click()');
// Wait only for these two local browser downloads, bounded at five seconds.
let saved;
for(let n=0;n<50;n++){
try{saved=[await readFile(new URL('mosaic-brand-review.txt',downloads),'utf8'),await readFile(new URL('mosaic-harbor-tokens.json',downloads),'utf8')];break;}catch{await new Promise(r=>setTimeout(r,100));}
}
assert(saved,'Local downloads did not complete');
assert(saved[0].includes('Prefer Relay.'));
assert.deepEqual(Object.keys(JSON.parse(saved[1]).modes),['light','dim','dark']);
report.checks.push({name:'Review notes and all-three-mode palette JSON downloaded and content-verified',result:'pass'});
// Double every computed text size, including authored pixel sizes.
await b.viewport(320,1000);
await b.evaluate(`(()=>{const sizes=[...document.querySelectorAll('body *')].map(e=>[e,parseFloat(getComputedStyle(e).fontSize)]);for(const[e,size]of sizes)e.style.fontSize=size*2+'px'})()`);
let expanded=await b.evaluate('({width:innerWidth,scroll:document.documentElement.scrollWidth})');
assert(expanded.scroll<=expanded.width,JSON.stringify(expanded));
await b.screenshot(new URL('board-all-text-200-320.png',out));
report.checks.push({name:'Every computed text size doubled at 320 px',result:'pass',measurement:expanded});
// Font failure and storage denial are injected only into this isolated test browser.
await b.call('Network.enable');await b.call('Network.setCacheDisabled',{cacheDisabled:true});
await b.call('Network.setBlockedURLs',{urls:['*woff2*']});
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:'Object.defineProperty(window,"localStorage",{get(){throw new DOMException("Unavailable","SecurityError")}})'});
await b.navigate(url);await change('palette','carmine');await mode('dim');
assert.equal(await b.evaluate('document.documentElement.dataset.palette'),'carmine');
expanded=await b.evaluate('({width:innerWidth,scroll:document.documentElement.scrollWidth})');
assert(expanded.scroll<=expanded.width,JSON.stringify(expanded));
assert(!(await b.evaluate('document.fonts.check(\'16px "DM Sans"\')')));
await b.screenshot(new URL('board-font-storage-fallback-320.png',out));
report.checks.push({name:'Blocked fonts and denied storage: controls still work and fallback reflows',result:'pass',measurement:expanded});
}catch(error){report.failure=error.stack;}
finally{await b.close();}
await writeFile(new URL('detail-verification.json',out),JSON.stringify(report,null,2)+'\n');
console.log(JSON.stringify(report,null,2));
if(report.failure)process.exitCode=1;