/** * Mobile-first QA: screenshots of the key pages at phone + desktop widths, plus automated checks: * - no horizontal overflow (scrollWidth <= innerWidth) * - interactive elements >= 44 px tall (buttons/links in header, tab bar, chips, metrics) * - fixed bottom tab bar does not overlap the footer (body padding-bottom) * - layout shift after fonts/charts settle (compare heights before/after) * Run: NODE_PATH=/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules node qa/screens.mjs [BASE_URL] */ import { chromium } from 'playwright'; import { mkdirSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; const BASE = process.argv[2] ?? process.env.BASE_URL ?? 'http://localhost:8290'; const OUT = new URL('./screens/', import.meta.url).pathname; mkdirSync(OUT, { recursive: true }); const PAGES = ['/', '/countries', '/countries/canada', '/countries/canada/economy']; const WIDTHS = [320, 360, 375, 390, 414, 430, 1280, 1440]; const report = []; const browser = await chromium.launch(); for (const width of WIDTHS) { const mobile = width < 768; const ctx = await browser.newContext({ viewport: { width, height: mobile ? 800 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: 'light' }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(String(e))); page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); for (const path of PAGES) { await page.goto(BASE + path, { waitUntil: 'networkidle' }); await page.evaluate(() => document.fonts.ready); const h1 = await page.evaluate(() => document.documentElement.scrollHeight); await page.waitForTimeout(600); const metrics = await page.evaluate(() => { const de = document.documentElement; const overflow = de.scrollWidth - de.clientWidth; // elements wider than the viewport const wide = [...document.querySelectorAll('body *')].filter((el) => { const r = el.getBoundingClientRect(); return r.right > de.clientWidth + 1 && r.width > 0; }).slice(0, 8).map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 3).join('.')} right=${Math.round(el.getBoundingClientRect().right)}`); const small = [...document.querySelectorAll('a,button,[role=button],input,select,summary')].filter((el) => { const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) return false; const cs = getComputedStyle(el); if (cs.visibility === 'hidden') return false; return r.height < 44 && r.width < 44; }).map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30)}" ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`); const smallH = [...document.querySelectorAll('a,button,[role=button],input,select,summary')].filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.height < 32; }).map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30)}" h=${Math.round(el.getBoundingClientRect().height)}`); const tab = document.querySelector('nav.fixed'); const bodyPad = parseFloat(getComputedStyle(document.body).paddingBottom); const footer = document.querySelector('footer'); return { overflow, wide, small, smallH: smallH.slice(0, 12), tabBar: tab ? tab.getBoundingClientRect().height : 0, bodyPad, footerBottom: footer ? footer.getBoundingClientRect().bottom + window.scrollY : 0, docH: de.scrollHeight }; }); const h2 = metrics.docH; const name = `${path === '/' ? 'home' : path.slice(1).replace(/\//g, '_')}-${width}.png`; await page.screenshot({ path: OUT + name, fullPage: true }); report.push({ path, width, overflow: metrics.overflow, wide: metrics.wide, small: metrics.small.slice(0, 10), smallH: metrics.smallH, tabBar: metrics.tabBar, bodyPad: metrics.bodyPad, cls: h2 - h1, errors: errors.splice(0), file: name }); } await ctx.close(); } // Dark-mode sample + favicon rasterisation { const ctx = await browser.newContext({ viewport: { width: 390, height: 800 }, colorScheme: 'dark', isMobile: true, hasTouch: true }); const page = await ctx.newPage(); await page.goto(BASE + '/countries/canada', { waitUntil: 'networkidle' }); await page.screenshot({ path: OUT + 'countries_canada-390-dark.png', fullPage: true }); await ctx.close(); } { const require = createRequire(import.meta.url); const { readFileSync } = require('node:fs'); const svg = readFileSync(new URL('../src/app/icon.svg', import.meta.url), 'utf8'); for (const [size, file] of [[512, '../src/app/icon.png'], [180, '../src/app/apple-icon.png']]) { const ctx = await browser.newContext({ viewport: { width: size, height: size }, deviceScaleFactor: 1 }); const page = await ctx.newPage(); await page.setContent(`${svg.replace(/width="\d+" height="\d+"/, `width="${size}" height="${size}"`)}`); await page.screenshot({ path: new URL(file, import.meta.url).pathname, omitBackground: true, clip: { x: 0, y: 0, width: size, height: size } }); await ctx.close(); } } await browser.close(); writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2)); for (const r of report) { const flags = [r.overflow > 0 ? `OVERFLOW +${r.overflow}px` : 'ok', r.small.length ? `${r.small.length} small targets` : '', r.cls ? `Δh ${r.cls}` : '', r.errors.length ? `${r.errors.length} console errors` : ''].filter(Boolean).join(' · '); console.log(`${r.width.toString().padStart(4)} ${r.path.padEnd(28)} ${flags}`); if (r.overflow > 0) console.log(' wide:', r.wide.join(' | ')); if (r.small.length) console.log(' small:', r.small.slice(0, 6).join(' | ')); if (r.errors.length) console.log(' errors:', r.errors.slice(0, 3).join(' | ')); }