#!/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)