fix(qa): the browser check measured 5 of 18 pages and heard no errors

Two gaps in the tool that exists to find defects.

Its default path list was five pages typed by hand: home, about, services,
contact and support. That is 5 of the 18 this site serves, and it included no
service and no industry page, so a defect on any of them was invisible to it.
The default is now whatever the target's own sitemap says it serves, and a
sitemap it cannot read exits 2 rather than quietly checking nothing.

It also listened for nothing the page said. A page that throws still paints, so
every measurement it takes can look healthy while the page is broken: React
reported a hydration mismatch on every page of this site for months and this
tool never noticed, because nothing here was listening. It now captures page
errors and console errors and reports them as findings.

Proven: a console.error added to the About page produced
"/about @390: the page logged an error", and a clean build reports nothing
across all 18 sitemap pages.

docs/qa/ClaudeQAPlan.md said "five pages, five widths" and it is now every page
in the sitemap, at four widths.

Closes #227.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-09-10 04:53:51 -05:00
parent ee5186c1ee
commit 7cd556c4b5
2 changed files with 34 additions and 3 deletions

View File

@ -82,7 +82,7 @@ confidence about *how much*, without measuring.
So, before filing a UI defect and before acting on one: So, before filing a UI defect and before acting on one:
```bash ```bash
node scripts/qa-browser.mjs # production, five pages, five widths node scripts/qa-browser.mjs # production, every page in its sitemap, four widths
node scripts/qa-browser.mjs --url http://localhost:3099 --viewports 320,768 node scripts/qa-browser.mjs --url http://localhost:3099 --viewports 320,768
``` ```

View File

@ -51,6 +51,7 @@
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import { execSync } from 'node:child_process' import { execSync } from 'node:child_process'
import path from 'node:path' import path from 'node:path'
import { parseSitemap } from './lib/html-audit.js'
const args = process.argv.slice(2) const args = process.argv.slice(2)
const opt = (name, dflt) => { const opt = (name, dflt) => {
@ -70,9 +71,30 @@ const collect = (name, dflt) => {
for (let j = i + 1; j < args.length && !args[j].startsWith('--'); j++) out.push(args[j]) for (let j = i + 1; j < args.length && !args[j].startsWith('--'); j++) out.push(args[j])
return out.length ? out : dflt return out.length ? out : dflt
} }
const PATHS = collect('paths', ['/', '/about', '/services', '/contact', '/support'])
const SHOTS = opt('shots', null) const SHOTS = opt('shots', null)
// The default path list used to be five hand-typed pages: the home page, about,
// services, contact and support. That is 5 of the 18 this site serves, and it
// included none of the service or industry pages, so a defect on any of them was
// invisible to the tool that exists to find defects. The default is now whatever
// the target itself says it serves.
//
// Failing to read the sitemap exits 2. "I checked nothing" must never look like
// "I found nothing".
let PATHS = collect('paths', null)
if (!PATHS) {
try {
const response = await fetch(`${URL_BASE}/sitemap.xml`, { signal: AbortSignal.timeout(20000) })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
PATHS = parseSitemap(await response.text()).map((entry) => entry.path)
if (!PATHS.length) throw new Error('it lists no pages')
} catch (e) {
console.error(`qa-browser: could not read ${URL_BASE}/sitemap.xml (${e.message}), so NOTHING was checked.`)
console.error(' Pass --paths to check specific pages instead.')
process.exit(2)
}
}
// playwright is global here. Resolve it explicitly rather than failing with a // 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 // bare MODULE_NOT_FOUND, which reads as "the script is broken" rather than
// "install this". // "install this".
@ -115,6 +137,14 @@ for (const p of PATHS) {
if (r.request().resourceType() === 'image' && r.status() >= 400) httpFailed.push(`${r.status()} ${r.url()}`) if (r.request().resourceType() === 'image' && r.status() >= 400) httpFailed.push(`${r.status()} ${r.url()}`)
}) })
// A page that throws still paints, so every measurement below can look
// healthy while the page is broken. React reported a hydration mismatch on
// every page of this site for months and nothing here noticed, because
// nothing here was listening.
const consoleErrors = []
page.on('pageerror', e => consoleErrors.push(String(e).split('\n')[0]))
page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text().split('\n')[0]) })
let resp let resp
try { try {
resp = await page.goto(URL_BASE + p, { waitUntil: 'networkidle', timeout: 45000 }) resp = await page.goto(URL_BASE + p, { waitUntil: 'networkidle', timeout: 45000 })
@ -179,6 +209,7 @@ for (const p of PATHS) {
if (m.cls > 0.1) { bits.push(`CLS ${m.cls}`); findings.push(`${p} @${width}: CLS ${m.cls} (>0.1)`) } 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.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('; ')}`) } if (m.over.length) { bits.push(`OVERFLOW ${m.over.length}`); findings.push(`${p} @${width}: past the right edge — ${m.over.join('; ')}`) }
if (consoleErrors.length) { bits.push(`JS-ERROR ${consoleErrors.length}`); findings.push(`${p} @${width}: the page logged an error — ${[...new Set(consoleErrors)].slice(0, 2).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'}`) 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'}`)
@ -196,6 +227,6 @@ if (findings.length) {
process.exit(1) process.exit(1)
} }
say(`qa-browser: nothing found across ${PATHS.length} path(s) x ${VIEWPORTS.length} viewport(s).`) 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(' That is not "the UI is correct" — it is these six measurements,')
say(' on these pages, at these widths. Nothing here opens a menu or') say(' on these pages, at these widths. Nothing here opens a menu or')
say(' uses a keyboard.') say(' uses a keyboard.')