Queue-North-Website/scripts/qa-browser.mjs

202 lines
9.3 KiB
JavaScript
Raw Permalink Normal View History

fix(ui): header CTA clipped at iPad portrait, and reCAPTCHA cut off at 320px Two real defects, found by rendering the site rather than reading it. Neither was among the twenty issues filed for Batches 10 and 11. #214 (P1) — at exactly 768px, iPad portrait, the header's "Request Consultation" CTA measured x 676-778 against a 768px viewport: 10px sliced off, with no scrollbar to reveal it because html/body carry overflow-x:hidden. At that width the burger menu is already hidden, so the primary conversion action was simply unreachable. The nav's five gap-6 gaps were the slack; gap-4 at md (gap-6 from lg) frees 40px, keeping the CTA on screen at md rather than deferring it to lg and leaving 768-1023px with no CTA at all. #215 (P2) — Google's reCAPTCHA checkbox iframe is a fixed 304px that cannot be resized. At 320px it measured x 41-345, so 25px of branding and the privacy and terms links were clipped. Scaled to 0.85 below 360px with the wrapper height reduced to match, since transform does not affect layout and the form would otherwise gain dead space. Also adds scripts/qa-browser.mjs, which found them. It measures horizontal scroll, broken images, elements past the right edge, CLS and LCP across pages and viewports. Written because seven of the ten issues in Batches 10 and 11 misstated their own evidence, and two of those would have made the site worse if actioned. Two false positives in the tool itself, both fixed before trusting it: - Lazy images below the fold read as broken. It now scrolls the page AND chases each un-loaded image individually, and reports observed HTTP failures apart from never-rendered images. - `--paths / --viewports 320` swallowed "320" as a path. Argument collection now stops at the next flag. Verified against a local production build: 5 paths x 5 viewports, zero findings. Before the fix the same run reported the CTA overflow at 768 on four pages and the reCAPTCHA overflow at 320 on /contact. Not yet live — this needs a release and a deploy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 04:05:37 -05:00
#!/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.')