234 lines
9.8 KiB
JavaScript
234 lines
9.8 KiB
JavaScript
|
|
#!/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)
|