#!/usr/bin/env node // // Render the site in a real browser at real viewports and MEASURE it. // // node scripts/qa-browser.mjs # production, default widths // node scripts/qa-browser.mjs --url http://localhost:3001 // node scripts/qa-browser.mjs --viewports 320,768 // node scripts/qa-browser.mjs --paths / /about /contact // node scripts/qa-browser.mjs --shots /tmp/qa # also write screenshots // // Exit codes: 0 nothing found. 1 findings, each named. 2 NOTHING WAS CHECKED — // playwright missing or the site unreachable. Two is not a pass. // // ## Which incident motivated it // // Batches 10 and 11 were twenty-ish UI and accessibility defects filed from // reading markup. **Seven of ten misstated their own evidence.** Two contrast // figures were simply wrong when computed; one proposed a colour measuring // 1.96:1 against the background it would sit on, which would have made the // footer materially worse; one asked for aria-labels whose existing versions // were already WCAG 2.5.3 Level A failures, so doing what it said would have // spread the defect; one described a clipping ancestor that does not exist; // one described an overlap that is a 12px gap; one described a readOnly // attribute that is not in the file. // // Every one of those took minutes to disprove with a browser and would have // taken hours to "fix". The cost of not having this script was not the fixes — // it was very nearly making the site worse, twice, on the strength of a // confident sentence. // // So: numbers, from the thing itself. Contrast is arithmetic, overlap is two // rectangles, and "does it clip" is a computed style you can read. // // ## What it measures // // horizontal scroll scrollWidth > innerWidth, and which element causes it // broken images after scrolling the whole page, so lazy ones are fair // CLS cumulative layout shift, good < 0.1 // LCP largest contentful paint, good < 2500ms // overflowing nodes any element whose right edge is past the viewport // // ## Why it is not wired into verify.sh // // playwright is not a dependency of this project — it is installed globally on // this machine. Adding ~300MB of browser to every clone to run a check nobody // runs on every commit is the wrong trade. This is a tool you reach for when a // UI defect is filed, and `docs/qa/ClaudeQAPlan.md` says to reach for it. // // If it is ever made a devDependency, wire it in as scripts/verify.d/40-browser. import { createRequire } from 'node:module' import { execSync } from 'node:child_process' import path from 'node:path' const args = process.argv.slice(2) const opt = (name, dflt) => { const i = args.indexOf(`--${name}`) return i === -1 ? dflt : args[i + 1] } const URL_BASE = (opt('url', 'https://queuenorth.com')).replace(/\/$/, '') const VIEWPORTS = (opt('viewports', '320,390,768,1024')).split(',').map(Number) // Stop at the next flag rather than filtering non-flags out of the whole tail: // `--paths / --viewports 320` otherwise swallowed "320" as a path and then // tried to navigate to it. Caught by the tool reporting four findings that were // its own argument handling. const collect = (name, dflt) => { const i = args.indexOf(`--${name}`) if (i === -1) return dflt const out = [] for (let j = i + 1; j < args.length && !args[j].startsWith('--'); j++) out.push(args[j]) return out.length ? out : dflt } const PATHS = collect('paths', ['/', '/about', '/services', '/contact', '/support']) const SHOTS = opt('shots', null) // playwright is global here. Resolve it explicitly rather than failing with a // bare MODULE_NOT_FOUND, which reads as "the script is broken" rather than // "install this". let chromium try { const require = createRequire(import.meta.url) let root try { root = require.resolve('playwright') } catch { const g = execSync('npm root -g', { encoding: 'utf8' }).trim() root = path.join(g, 'playwright', 'index.js') } ;({ chromium } = require(root)) } catch (e) { console.error('qa-browser: playwright is not available, so NOTHING was checked.') console.error(' npm i -g playwright && npx playwright install chromium') process.exit(2) } const findings = [] const say = (...a) => console.log(...a) const browser = await chromium.launch().catch(e => { console.error('qa-browser: could not launch chromium, so nothing was checked:', e.message) process.exit(2) }) for (const p of PATHS) { for (const width of VIEWPORTS) { const page = await browser.newPage({ viewport: { width, height: 900 } }) await page.addInitScript(() => { window.__cls = 0; window.__lcp = 0 new PerformanceObserver(l => { for (const e of l.getEntries()) if (!e.hadRecentInput) window.__cls += e.value }) .observe({ type: 'layout-shift', buffered: true }) new PerformanceObserver(l => { for (const e of l.getEntries()) window.__lcp = e.startTime }) .observe({ type: 'largest-contentful-paint', buffered: true }) }) const httpFailed = [] page.on('response', r => { if (r.request().resourceType() === 'image' && r.status() >= 400) httpFailed.push(`${r.status()} ${r.url()}`) }) let resp try { resp = await page.goto(URL_BASE + p, { waitUntil: 'networkidle', timeout: 45000 }) } catch (e) { findings.push(`${p} @${width}: could not load — ${e.message.split('\n')[0]}`) await page.close(); continue } if (!resp || resp.status() >= 400) { findings.push(`${p} @${width}: HTTP ${resp ? resp.status() : '?'}`) await page.close(); continue } // Scroll the whole page so lazy images are actually requested, then chase // any that still have not loaded by scrolling to each one directly. // // Both passes are needed and the second is the one that matters. A coarse // scroll can outrun the intersection observer, and the first version of // this reported a perfectly healthy lazy badge as broken at three // viewports — a false positive in the tool, on the very run that was // meant to demonstrate the tool. Anything that survives BOTH passes plus // an observed HTTP failure is worth reporting; anything less is noise, and // noise is how a checker gets ignored. await page.evaluate(async () => { for (let y = 0; y < document.body.scrollHeight; y += 300) { window.scrollTo(0, y); await new Promise(r => setTimeout(r, 80)) } window.scrollTo(0, 0) }) await page.waitForTimeout(800) const stragglers = await page.$$('img') for (const h of stragglers) { const ok = await h.evaluate(i => i.complete && i.naturalWidth > 0) if (ok) continue await h.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {}) await page.waitForTimeout(400) } await page.evaluate(() => window.scrollTo(0, 0)) await page.waitForTimeout(600) const m = await page.evaluate(() => { const vw = window.innerWidth const over = [...document.querySelectorAll('body *')] .filter(e => { const b = e.getBoundingClientRect(); return b.width > 0 && b.right > vw + 1 }) .slice(0, 5) .map(e => `${e.tagName.toLowerCase()}${e.className ? '.' + String(e.className).split(' ')[0] : ''} (right ${Math.round(e.getBoundingClientRect().right)})`) return { vw, scrollWidth: document.documentElement.scrollWidth, broken: [...document.images].filter(i => !(i.complete && i.naturalWidth > 0)).map(i => i.getAttribute('src')), images: document.images.length, cls: +(window.__cls || 0).toFixed(4), lcp: Math.round(window.__lcp || 0), over, } }) const bits = [] if (m.scrollWidth > m.vw) { bits.push(`H-SCROLL ${m.scrollWidth}>${m.vw}`); findings.push(`${p} @${width}: horizontal scroll ${m.scrollWidth} > ${m.vw}`) } if (httpFailed.length) { bits.push(`HTTP-FAIL ${httpFailed.length}`); findings.push(`${p} @${width}: image request failed — ${httpFailed.join(', ')}`) } if (m.broken.length) { bits.push(`BROKEN ${m.broken.length}`); findings.push(`${p} @${width}: image never rendered after being scrolled to — ${m.broken.join(', ')}`) } if (m.cls > 0.1) { bits.push(`CLS ${m.cls}`); findings.push(`${p} @${width}: CLS ${m.cls} (>0.1)`) } if (m.lcp > 2500) { bits.push(`LCP ${m.lcp}ms`); findings.push(`${p} @${width}: LCP ${m.lcp}ms (>2500)`) } if (m.over.length) { bits.push(`OVERFLOW ${m.over.length}`); findings.push(`${p} @${width}: past the right edge — ${m.over.join('; ')}`) } say(` ${p.padEnd(12)} @${String(width).padEnd(5)} imgs=${String(m.images).padEnd(3)} cls=${String(m.cls).padEnd(7)} lcp=${String(m.lcp + 'ms').padEnd(8)} ${bits.length ? '‼ ' + bits.join(' ') : 'ok'}`) if (SHOTS) await page.screenshot({ path: `${SHOTS}/${p.replace(/\//g, '_') || 'root'}-${width}.png`, fullPage: false }) await page.close() } } await browser.close() say('') if (findings.length) { say(`qa-browser: ${findings.length} finding(s):`) for (const f of findings) say(` - ${f}`) process.exit(1) } say(`qa-browser: nothing found across ${PATHS.length} path(s) x ${VIEWPORTS.length} viewport(s).`) say(' That is not "the UI is correct" — it is these five measurements,') say(' on these pages, at these widths. Nothing here opens a menu or') say(' uses a keyboard.')