Queue-North-Website/scripts/device-sweep.mjs

234 lines
9.8 KiB
JavaScript
Executable File

#!/usr/bin/env node
//
// Render every page on emulated phones and tablets and MEASURE the layout.
//
// node scripts/device-sweep.mjs # localhost:3001
// node scripts/device-sweep.mjs --url https://queuenorth.com
// node scripts/device-sweep.mjs --devices "iPhone SE,iPad Mini"
// node scripts/device-sweep.mjs --report /tmp/sweep.md --shots /tmp/sweep
//
// Exit codes: 0 nothing found. 1 findings. 2 NOTHING WAS SWEPT — playwright
// missing, chromium unlaunchable, or no sitemap. Two is not a pass.
//
// ## Which incident motivated it
//
// #214 was "the header CTA is clipped at iPad portrait". It was fixed in v0.9.5
// by tightening the nav gaps, checked in a desktop browser window sized to 768,
// and released. The check was worthless: a desktop window at 768 has a scrollbar,
// so the layout viewport was ~753px, the md breakpoint never engaged, and the
// desktop header the fix was about was never on screen. The CTA was still 25px
// past the right edge in production, on every page, and body{overflow-x:hidden}
// sliced it off with no scrollbar to hint that anything was missing.
//
// A real device profile has no scrollbar inset, so 768 means 768. It also brings
// deviceScaleFactor, isMobile and hasTouch, which change hover media queries and
// text metrics. Those differences are the whole reason this exists alongside
// qa-browser.mjs: that script varies width, this one varies device.
//
// ## How it differs from qa-browser.mjs
//
// qa-browser a few widths, one desktop context; contrast, CLS, LCP,
// broken images, horizontal scroll
// device-sweep ten real device profiles; eight classes of layout defect
// from scripts/lib/css-audit.js, measured against each box's
// nearest CLIPPING ancestor rather than document.scrollWidth
//
// Neither replaces the other. Reach for both when a UI defect is filed.
//
// ## Why it is not wired into verify.sh
//
// Same trade as qa-browser: playwright is a global install here, not a
// dependency, and this needs a server already serving the build. A guard that
// cannot run on a clean clone is a guard that gets skipped, and verify.sh
// treating a skip as a pass is the failure mode GUARDS.md exists to prevent.
import { createRequire } from 'node:module'
import { execSync } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { audit } from './lib/css-audit.js'
import { parseSitemap } from './lib/html-audit.js'
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', 'http://localhost:3001').replace(/\/$/, '')
const REPORT = opt('report', null)
const SHOTS = opt('shots', null)
const ONLY = opt('devices', null)?.split(',').map((s) => s.trim())
let chromium
let devices
try {
const require = createRequire(import.meta.url)
let root
try {
root = require.resolve('playwright')
} catch {
root = path.join(execSync('npm root -g', { encoding: 'utf8' }).trim(), 'playwright', 'index.js')
}
;({ chromium, devices } = require(root))
} catch {
console.error('device-sweep: playwright is not available, so NOTHING was swept.')
console.error(' npm i -g playwright && npx playwright install chromium')
process.exit(2)
}
// Portrait and landscape both, because a tablet is held both ways and a phone in
// landscape is the shape that catches a menu taller than the viewport. iPad Mini
// portrait is 768 exactly, which is the md breakpoint, which is where #214 was.
// Playwright has no landscape entries for these, so the two rotated profiles keep
// the device's scale factor and touch flags and swap the viewport by hand.
const PROFILES = [
['iPhone SE', devices['iPhone SE']],
['iPhone 12', devices['iPhone 12']],
['iPhone 14 Pro Max', devices['iPhone 14 Pro Max']],
['Pixel 7', devices['Pixel 7']],
['Galaxy S9+', devices['Galaxy S9+']],
['iPhone 12 landscape', { ...devices['iPhone 12'], viewport: { width: 664, height: 390 } }],
['iPad Mini', devices['iPad Mini']],
['iPad Mini landscape', { ...devices['iPad Mini'], viewport: { width: 1024, height: 768 } }],
['iPad Pro 11', devices['iPad Pro 11']],
['iPad Pro 11 landscape', { ...devices['iPad Pro 11'], viewport: { width: 1194, height: 834 } }],
].filter(([label, profile]) => profile && (!ONLY || ONLY.includes(label)))
if (!PROFILES.length) {
console.error(`device-sweep: no device profile matched ${ONLY?.join(', ')}, so NOTHING was swept.`)
process.exit(2)
}
// Whatever the target says it serves, plus a path it does not: 404.html is a page
// users reach and it has never been in anybody's hand-typed list.
let routes
try {
const response = await fetch(`${URL_BASE}/sitemap.xml`, { signal: AbortSignal.timeout(20000) })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
routes = parseSitemap(await response.text()).map((entry) => entry.path)
if (!routes.length) throw new Error('it lists no pages')
} catch (error) {
console.error(`device-sweep: could not read ${URL_BASE}/sitemap.xml (${error.message}), so NOTHING was swept.`)
process.exit(2)
}
routes.push('/no-such-page')
const browser = await chromium.launch().catch((error) => {
console.error('device-sweep: could not launch chromium, so NOTHING was swept:', error.message)
process.exit(2)
})
if (SHOTS) mkdirSync(SHOTS, { recursive: true })
const AUDIT_SRC = audit.toString()
const findings = []
let loads = 0
for (const [label, profile] of PROFILES) {
const context = await browser.newContext({ ...profile })
const page = await context.newPage()
for (const route of routes) {
let response
try {
response = await page.goto(`${URL_BASE}${route}`, { waitUntil: 'networkidle', timeout: 45000 })
} catch (error) {
findings.push({ kind: 'load_failed', severity: 'blocking', route, device: label, says: error.message.split('\n')[0], path: '', text: '' })
continue
}
loads++
if (!response || (response.status() >= 400 && route !== '/no-such-page')) {
findings.push({ kind: 'http_error', severity: 'blocking', route, device: label, says: `HTTP ${response?.status()}`, path: '', text: '' })
continue
}
// Scroll the whole page before measuring, so lazy images have loaded and
// sticky elements have been in their stuck state. Measuring at the top only
// reports a page nobody has used yet.
await page.evaluate(async () => {
for (let y = 0; y < document.body.scrollHeight; y += 400) {
window.scrollTo(0, y)
await new Promise((resolve) => setTimeout(resolve, 60))
}
window.scrollTo(0, 0)
})
await page.waitForTimeout(500)
for (const finding of await page.evaluate(`(${AUDIT_SRC})()`)) {
findings.push({ ...finding, route, device: label })
}
if (SHOTS) {
const name = `${label.replace(/\W+/g, '-')}${route.replace(/\//g, '_') || '_root'}.png`
await page.screenshot({ path: path.join(SHOTS, name), fullPage: true })
}
}
await context.close()
}
await browser.close()
if (!loads) {
console.error(`device-sweep: every page load failed against ${URL_BASE}, so NOTHING was measured.`)
process.exit(2)
}
// The same defect on six devices is one defect. Group on what identifies it —
// route, element, text — and keep the device list, because "only iPad Mini"
// versus "all ten" is the difference between a breakpoint bug and a layout bug.
const groups = new Map()
for (const finding of findings) {
const key = `${finding.kind}|${finding.route}|${finding.path}|${(finding.text || '').slice(0, 40)}`
if (!groups.has(key)) groups.set(key, { ...finding, devices: new Set(), count: 0 })
groups.get(key).devices.add(finding.device)
groups.get(key).count++
}
const rows = [...groups.values()].sort(
(a, b) => (b.severity === 'blocking') - (a.severity === 'blocking') || b.devices.size - a.devices.size,
)
const bySeverity = (severity) => rows.filter((row) => row.severity === severity)
if (REPORT) {
const lines = [
`# Device sweep: ${URL_BASE}`,
'',
`${routes.length} routes x ${PROFILES.length} devices = ${loads} page loads`,
`Devices: ${PROFILES.map(([label]) => label).join(', ')}`,
'',
`**${bySeverity('blocking').length} blocking, ${bySeverity('high').length} high, ${bySeverity('info').length} informational** (grouped from ${findings.length})`,
'',
]
for (const severity of ['blocking', 'high', 'info']) {
const group = bySeverity(severity)
if (!group.length) continue
lines.push(`## ${severity}`, '')
for (const row of group) {
lines.push(
`- **${row.route}** ${row.kind}: ${row.says || ''}`,
` - \`${row.path}\`${row.text ? ` — text: ${JSON.stringify(String(row.text).slice(0, 60))}` : ''}`,
` - on ${[...row.devices].join(', ')}`,
row.detail ? ` - \`${JSON.stringify(row.detail)}\`` : '',
)
}
lines.push('')
}
mkdirSync(path.dirname(path.resolve(REPORT)), { recursive: true })
writeFileSync(REPORT, lines.filter((line) => line !== '').join('\n') + '\n')
}
console.log(`device-sweep: ${loads} page loads across ${PROFILES.length} device(s) of ${URL_BASE}`)
for (const severity of ['blocking', 'high', 'info']) {
const counts = new Map()
for (const row of bySeverity(severity)) counts.set(row.kind, (counts.get(row.kind) || 0) + 1)
for (const [kind, n] of counts) console.log(` ${severity}: ${kind} x${n}`)
}
if (REPORT) console.log(` report: ${REPORT}`)
if (!rows.length) {
console.log(' nothing wrong.')
process.exit(0)
}
for (const row of rows.slice(0, 20)) {
console.log(` ${row.severity.padEnd(8)} ${row.route} ${row.kind}: ${row.says || ''} [${row.path}]`)
}
if (rows.length > 20) console.log(` ...and ${rows.length - 20} more${REPORT ? ' in the report' : ' (pass --report to list them all)'}`)
process.exit(1)