← back to Agent Cabinet
front-page-audit/proof/live-ux-canary.mjs
85 lines
// live-ux-canary.mjs — READ-ONLY post-deploy canary for the UX-primitives fleet rollout (TK-12031).
// For each LIVE site: load in a real browser, sample the grid every 50ms during load to catch the
// skeleton window, then assert after settle: UXGrid initialised, skeletons=0, products>0, empty=0,
// sort <select> + density <input type=range> present, no bundle-attributable console/page errors.
// Usage: node live-ux-canary.mjs <sites-file> [engine=chromium|webkit] [out.json]
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
process.env.NODE_PATH = '/Users/macstudio3/.npm-global/lib/node_modules';
require('module').Module._initPaths();
const { chromium, webkit } = require('playwright');
import fs from 'fs';
import crypto from 'crypto';
const [,, sitesFile, engineName='chromium', outFile=`live-canary-${new Date().toISOString().replace(/[:.]/g,'-')}.json`] = process.argv;
const sites = fs.readFileSync(sitesFile,'utf8').split('\n').map(s=>s.trim()).filter(Boolean);
const BUNDLE_RX = /ux-primitives|UXGrid|ModalRig|Bento|CommandPalette|Toast|Skeleton|EmptyState|copyToClipboard/i;
// The deploy script (_shared/scripts/redeploy-ux-delta.sh) computes CANON=$(shasum -a 256
// ux-primitives.bundle.js | cut -c1-12) and injects `ux-primitives.bundle v1.1.1#$CANON`. That hash is
// content-derived, so any rebuild rotates it — a hardcoded literal reds the whole fleet after a routine
// rebuild even though every site is correctly updated. Compute the expected marker the SAME way the deployer
// does, from the canonical bundle, at startup, and gate against it so a site left on a STALE bundle (wrong
// hash) is still caught. A caller may pin an exact marker via EXPECTED_MARKER (UX_MARKER kept for back-compat).
// If the bundle can't be read we do NOT silently pass: markerCanonical goes false with a surfaced reason.
const CANON_BUNDLE = process.env.UX_BUNDLE || '/Users/macstudio3/Projects/_shared/ux-primitives.bundle.js';
const UX_VERSION = process.env.UX_VERSION || '1.1.1';
let EXPECT_MARKER = process.env.EXPECTED_MARKER || process.env.UX_MARKER || null;
let markerReason = null;
if (!EXPECT_MARKER) {
try {
const h = crypto.createHash('sha256').update(fs.readFileSync(CANON_BUNDLE)).digest('hex').slice(0,12);
EXPECT_MARKER = `ux-primitives.bundle v${UX_VERSION}#${h}`;
} catch (e) {
markerReason = `bundle unreadable at ${CANON_BUNDLE}: ${String(e.message||e).slice(0,120)}`;
}
}
console.log(`expected marker: ${EXPECT_MARKER || '(UNRESOLVED — '+markerReason+')'}`);
const engine = engineName==='webkit'?webkit:chromium;
const browser = await engine.launch({ headless:true });
const out=[];
for (const site of sites){
const url = `https://${site}.com/`;
const ctx = await browser.newContext({ viewport:{width:1280,height:900}, ignoreHTTPSErrors:true });
const page = await ctx.newPage();
const consoleErr=[], pageErr=[];
page.on('console', m=>{ if(m.type()==='error') consoleErr.push(m.text().slice(0,200)); });
page.on('pageerror', e=>pageErr.push(String(e.message||e).slice(0,200)));
// block third-party ad/analytics noise so it can't mask or delay the measurement
await page.route(/googlesyndication|doubleclick|google-analytics|googletagmanager|ads\.agentabrams|facebook|tiktok/i, r=>r.abort());
const rec={ site, url, engine:engineName };
try{
const t0=Date.now();
const nav = page.goto(url,{ waitUntil:'domcontentloaded', timeout:45000 });
let maxSkel=0, samples=0;
const sampler=(async()=>{ while(Date.now()-t0<12000){ try{ const n=await page.evaluate(()=>{const g=document.querySelector('[data-ux-grid]'); return g?g.querySelectorAll('[data-ux-skel-holder],[data-ux-skel],.ux-skel').length:-1;}); if(n>maxSkel) maxSkel=n; samples++; }catch{} await new Promise(r=>setTimeout(r,50)); } })();
await nav;
await page.waitForLoadState('load',{timeout:45000}).catch(()=>{});
await sampler;
await page.waitForTimeout(1500);
const st = await page.evaluate(()=>{
const g=document.querySelector('[data-ux-grid]');
const marker=(document.documentElement.outerHTML.match(/ux-primitives\.bundle v[0-9.]+#[0-9a-f]+/)||[null])[0];
const q=s=>g?g.querySelectorAll(s).length:-1;
const products=g?[...g.children].filter(c=>c.nodeType===1&&!c.matches('[data-ux-skel-holder],[data-ux-skel],[data-ux-empty-holder]')&&!c.classList.contains('ux-skel')).length:-1;
return { hasGrid:!!g, marker, skel:q('[data-ux-skel-holder],[data-ux-skel],.ux-skel'), empty:q('[data-ux-empty-holder]'), products,
ux: typeof window.UXGrid, sort: !!document.querySelector('select[id*=sort i],select[name*=sort i],#sort,[data-sort]'), density: !!document.querySelector('input[type=range]'),
title: document.title.slice(0,60) };
});
Object.assign(rec, st, { maxSkelDuringLoad:maxSkel, samples });
rec.bundleErrors=[...consoleErr,...pageErr].filter(x=>BUNDLE_RX.test(x));
rec.otherErrors=[...consoleErr,...pageErr].filter(x=>!BUNDLE_RX.test(x)).length;
rec.pageErrorsAll=pageErr.length;
rec.markerExpected=EXPECT_MARKER; if(markerReason) rec.markerReason=markerReason;
rec.criteria={ uxInit: st.ux==='object', markerCanonical: EXPECT_MARKER ? (st.marker===EXPECT_MARKER) : false, gridWired: st.hasGrid,
skeletonSeen: maxSkel>0, skeletonCleared: st.skel===0, productsRender: st.products>0, noEmptyWhenProducts: st.empty===0,
controls: st.sort && st.density, noBundleErr: rec.bundleErrors.length===0 };
// skeletonSeen is INFORMATIONAL on live (a fast CDN load can legitimately beat the 50ms sampler); it does not gate pass
const {skeletonSeen, ...gating}=rec.criteria; rec.pass=Object.values(gating).every(Boolean);
}catch(e){ rec.error=String(e.message||e).slice(0,200); rec.pass=false; }
await ctx.close();
console.log(`${site}: pass=${rec.pass} ux=${rec.ux} marker=${rec.marker} skelMax=${rec.maxSkelDuringLoad} skel=${rec.skel} products=${rec.products} empty=${rec.empty} sort=${rec.sort} density=${rec.density} bundleErr=${JSON.stringify(rec.bundleErrors)} ${rec.error?'ERR='+rec.error:''}`);
out.push(rec);
}
await browser.close();
fs.writeFileSync(outFile, JSON.stringify(out,null,1));
const p=out.filter(r=>r.pass).length; console.log(`\nSUMMARY ${engineName}: ${p}/${out.length} pass -> ${outFile}`);