Files
aia-website/www/tools/campaign-browser.mjs

119 lines
8.3 KiB
JavaScript

/** Guarded loopback-only campaign verification. Optional source build for visual comparison.
* PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs node tools/campaign-browser.mjs
* CAMPAIGN_REFERENCE_DIST=/tmp/reference/dist CAMPAIGN_BASELINE_DIST=/tmp/baseline ...
* Screenshots and evidence remain outside the repository. No submission tests.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import http from 'node:http';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
const root = fileURLToPath(new URL('../', import.meta.url));
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ? pathToFileURL(process.env.PLAYWRIGHT_MODULE).href : 'playwright');
const output = await fs.mkdtemp('/tmp/aia-campaign-browser-');
const evidence = { date: new Date().toISOString(), output, status: 'pending', error: '', browser: '', checks: [], blocked: [], screenshots: [], errors: [] };
const servers = [];
let browser;
const mime = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.webp': 'image/webp', '.png': 'image/png', '.json': 'application/json' };
async function serve(directory) {
const base=path.resolve(directory);
const server=http.createServer(async (req,res)=>{
try {
assert.ok(['GET','HEAD'].includes(req.method));
let file=path.resolve(base,'.'+decodeURIComponent(new URL(req.url,'http://localhost').pathname));
assert.ok(file.startsWith(base+'/') || file===base);
if((await fs.stat(file)).isDirectory()) file=path.join(file,'index.html');
res.setHeader('Content-Type',mime[path.extname(file)] || 'application/octet-stream');res.end(await fs.readFile(file));
} catch {res.statusCode=404;res.end('Not found');}
});
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));servers.push(server);
return `http://127.0.0.1:${server.address().port}`;
}
const routes=['/lp/aba-therapy','/lp/aba-insurance','/lp/aba-ahcccs','/lp/aba-private-pay'];
try {
const origin=await serve(path.join(root,'dist'));
const reference=process.env.CAMPAIGN_REFERENCE_DIST ? await serve(process.env.CAMPAIGN_REFERENCE_DIST) : null;
const baseline=process.env.CAMPAIGN_BASELINE_DIST ? await serve(process.env.CAMPAIGN_BASELINE_DIST) : null;
const allowed=new Set([origin,reference,baseline].filter(Boolean));
browser=await chromium.launch({headless:true,...(process.env.CHROMIUM_EXECUTABLE ? {executablePath:process.env.CHROMIUM_EXECUTABLE}: {})});
evidence.browser=browser.version();
async function context(javaScriptEnabled=true) {
const ctx=await browser.newContext({javaScriptEnabled,serviceWorkers:'block',acceptDownloads:false,deviceScaleFactor:1});
await ctx.routeWebSocket('**/*',socket=>socket.close());
await ctx.route('**/*',route=>{
const r=route.request(),u=new URL(r.url());
if(!allowed.has(u.origin) || !['GET','HEAD'].includes(r.method())) {evidence.blocked.push({origin:u.origin,path:u.pathname,method:r.method()});return route.abort();}
return route.continue();
});
return ctx;
}
const ctx=await context();const page=await ctx.newPage();
page.on('pageerror',err=>evidence.errors.push(err.message));
async function visit(base,route,width) {
await page.setViewportSize({width,height:900});
const response=await page.goto(base+route,{waitUntil:'networkidle'});assert.equal(response.status(),200);
await page.evaluate(async()=>{
await document.fonts.ready;
document.querySelectorAll('img[loading="lazy"]').forEach(img=>img.loading='eager');
await Promise.all([...document.images].map(img=>img.decode().catch(()=>{})));
});
}
async function capture(label,route,width) {
const filename=`${label}-${route.replaceAll('/','_')}-${width}.png`;
const buffer=await page.screenshot({path:path.join(output,filename),fullPage:true});
evidence.screenshots.push({file:filename,sha256:createHash('sha256').update(buffer).digest('hex')});
}
async function design() {return page.evaluate(()=>({
title:document.title,h1:document.querySelector('h1').textContent,
sections:[...document.querySelectorAll('main > section:not(.consultation)')].map(el=>{
const r=el.getBoundingClientRect(),s=getComputedStyle(el);
return {text:el.innerText.replace(/\s+/g,' ').trim(),x:r.x,y:r.y,width:r.width,height:r.height,background:s.backgroundColor};
}),
headingFont:getComputedStyle(document.querySelector('h1')).fontFamily,
bodyFont:getComputedStyle(document.body).fontFamily,
overflow:document.documentElement.scrollWidth>innerWidth,
images:[...document.images].map(i=>({alt:i.alt,width:i.naturalWidth,loaded:i.complete&&i.naturalWidth>0}))
}));}
for(const width of [320,390,768,1440]) for(const route of routes) {
let source;
if(reference){await visit(reference,route,width);source=await design();await capture('source',route,width);}
await visit(origin,route,width);const result=await design();
assert.equal(result.overflow,false,route+' overflow');assert.ok(result.images.every(i=>i.loaded));
assert.equal(await page.locator('meta[name="robots"]').getAttribute('content'),'noindex,nofollow');
assert.equal(await page.locator('link[rel="canonical"]').getAttribute('href'),'https://www.azinstitute4autism.com'+route);
assert.equal(await page.locator('form').count(),0);assert.equal(await page.locator('#lead-form [name]:enabled').count(),0);
assert.equal(await page.locator('#lead-form button').isDisabled(),true);
if(source) {
assert.deepEqual(result.sections,source.sections,'Source content, section geometry and backgrounds');
assert.equal(result.title,source.title);assert.equal(result.h1,source.h1);
assert.equal(result.headingFont,source.headingFont);assert.equal(result.bodyFont,source.bodyFont);
assert.deepEqual(result.images,source.images);
}
await capture('integrated',route,width);
const faq=page.locator('.faq details').first();await faq.locator('summary').focus();await page.keyboard.press('Enter');assert.equal(await faq.getAttribute('open'),'');
const focus=await faq.locator('summary').evaluate(el=>getComputedStyle(el).outlineStyle);assert.notEqual(focus,'none');
await faq.locator('summary').press('Enter');
await page.locator('.hero__actions a').first().click();assert.equal(new URL(page.url()).hash,'#consultation');
evidence.checks.push({route,width,sourceParity:!!source,overflow:false,assets:true,faqKeyboard:true,cta:true,forms:'off'});
}
// Source payer links are retained; arbitrary attribution parameters are not forwarded.
await visit(origin,routes[0]+'?utm_term=synthetic&gclid=synthetic',390);
for(const a of await page.locator('[data-payment-intent]').all())assert.ok(!(await a.getAttribute('href')).includes('?'));
const nojs=await context(false);const np=await nojs.newPage();
for(const route of routes){await np.goto(origin+route);assert.ok(await np.locator('h1').isVisible());assert.equal(await np.locator('form').count(),0);assert.ok(await np.locator('#campaign-form-status').isVisible());await np.locator('.faq summary').first().click();assert.equal(await np.locator('.faq details').first().getAttribute('open'),'');}
evidence.noJavaScriptRoutes=routes;await nojs.close();
// Existing site's style sheets and geometry must not inherit the campaign CSS.
if(baseline) for(const route of ['/','/es','/library','/aba-therapy']) for(const width of [390,1440]) {
const snapshots=[];
for(const base of [baseline,origin]) {
await visit(base,route,width);
snapshots.push(await page.evaluate(()=>({title:document.title,css:[...document.querySelectorAll('link[rel="stylesheet"]')].map(l=>new URL(l.href).pathname),font:getComputedStyle(document.body).fontFamily,h1:getComputedStyle(document.querySelector('h1')).fontSize,width:document.documentElement.scrollWidth})));
}
assert.deepEqual(snapshots[0],snapshots[1]);evidence.checks.push({route,width,mainSiteUnchanged:true});
}
assert.deepEqual(evidence.blocked,[],'Unexpected request attempt');assert.deepEqual(evidence.errors,[]);
evidence.status='passed';await ctx.close();
} catch(error){evidence.status='failed';evidence.error=String(error.stack);process.exitCode=1;}
finally{await browser?.close();await Promise.all(servers.map(s=>new Promise(resolve=>s.close(resolve))));await fs.writeFile(path.join(output,'evidence.json'),JSON.stringify(evidence,null,2)+'\n');console.log(JSON.stringify({status:evidence.status,error:evidence.error,checks:evidence.checks.length,screenshots:evidence.screenshots.length,output},null,2));}