202 lines
9.3 KiB
JavaScript
Executable File
202 lines
9.3 KiB
JavaScript
Executable File
#!/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.')
|