/** * Polish sweep (real API): every public route × 320/375/390/430/1280/1440 × light/dark. * Asserts: no horizontal overflow, tap targets ≥ 44 px on phones, no console errors, no layout shift after * fonts/charts settle (PerformanceObserver layout-shift, CLS > 0.02 flagged), no mid-word truncation at 320 * (`.truncate` elements that actually clip), no "undefined"/"NaN"/"null" in the visible text, footer credits * present. Screenshots → qa/screens/polish/--.png, report → qa/screens/polish/report.json. * * node qa/polish-sweep.mjs [BASE_URL] [--quick] (--quick: 390 + 1440 light only, 10 routes) */ import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs'; import { mkdirSync, writeFileSync } from 'node:fs'; const args = process.argv.slice(2); const quick = args.includes('--quick'); const BASE = args.find((a) => a.startsWith('http')) ?? process.env.BASE_URL ?? 'http://localhost:8290'; const OUT = new URL('./screens/polish/', import.meta.url).pathname; mkdirSync(OUT, { recursive: true }); const ROUTES_FULL = [ '/', '/countries', '/countries/canada', '/countries/canada/economy', '/countries/nigeria', '/countries/japan/energy', '/compare', '/compare/canada/united-states/france', '/compare/canada/united-states/france?tab=economy', '/rankings', '/rankings/gdp-per-capita', '/rankings/life-expectancy?group=oecd', '/indicators', '/indicators/life-expectancy', '/indicators/inflation', '/regions', '/regions/oecd', '/explore', '/changes', '/data', '/sources', '/sources/worldbank', '/methodology', '/api', '/?search=can', ]; const ROUTES_QUICK = ['/', '/countries', '/countries/canada', '/countries/canada/economy', '/compare/canada/united-states/france?tab=economy', '/rankings/gdp-per-capita', '/indicators/life-expectancy', '/regions/oecd', '/changes', '/api']; const ROUTES = quick ? ROUTES_QUICK : ROUTES_FULL; const WIDTHS = quick ? [390, 1440] : [320, 375, 390, 430, 1280, 1440]; const THEMES = quick ? ['light'] : ['light', 'dark']; const slug = (p) => (p === '/' ? 'home' : p.slice(1).replace(/[/?=&]+/g, '_')); const report = []; const browser = await chromium.launch(); for (const theme of THEMES) { for (const width of WIDTHS) { const mobile = width < 768; const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme, reducedMotion: 'no-preference' }); await ctx.addInitScript(() => { window.__cls = 0; window.__clsAfterSettle = 0; window.__settled = false; try { new PerformanceObserver((l) => { for (const e of l.getEntries()) { if (e.hadRecentInput) continue; window.__cls += e.value; if (window.__settled) window.__clsAfterSettle += e.value; } }).observe({ type: 'layout-shift', buffered: true }); } catch {} }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(`pageerror: ${String(e).slice(0, 200)}`)); page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text().slice(0, 200)); }); for (const path of ROUTES) { let status = 0; try { const resp = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 60_000 }); status = resp?.status() ?? 0; } catch (e) { report.push({ path, width, theme, status: 'ERR', error: String(e).slice(0, 200) }); continue; } await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(700); // "/?search=" is not a route: open the search dialog from the header and type the query. const searchQ = /[?&]search=([^&]+)/.exec(path)?.[1]; if (searchQ) { try { // The header renders two triggers (icon on phones, field on desktop): click the visible one. const trigger = page.locator('header button[aria-label="Open search"]').locator('visible=true').first(); await trigger.click({ timeout: 10_000 }); const input = page.locator('dialog input').first(); await input.waitFor({ timeout: 10_000 }); await input.fill(decodeURIComponent(searchQ)); await page.waitForLoadState('networkidle').catch(() => {}); await page.waitForTimeout(800); } catch (e) { errors.push(`search dialog: ${String(e).slice(0, 120)}`); } } // Scroll through the page so lazy charts mount, then back to top; measure shift after settle. if (!searchQ) await page.evaluate(async () => { const h = document.documentElement.scrollHeight; for (let y = 0; y < h; y += 700) { window.scrollTo(0, y); await new Promise((r) => setTimeout(r, 60)); } window.scrollTo(0, 0); }); await page.waitForLoadState('networkidle').catch(() => {}); await page.waitForTimeout(500); await page.evaluate(() => { window.__settled = true; }); await page.waitForTimeout(600); let m; try { m = await page.evaluate( ({ mobile, width }) => { const de = document.documentElement; const overflow = de.scrollWidth - de.clientWidth; const vis = (el) => { const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) return false; const cs = getComputedStyle(el); return cs.visibility !== 'hidden' && cs.display !== 'none'; }; const wide = [...document.querySelectorAll('body *')] .filter((el) => { const r = el.getBoundingClientRect(); return r.right > de.clientWidth + 1 && r.width > 0; }) .slice(0, 6) .map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 3).join('.')} right=${Math.round(el.getBoundingClientRect().right)}`); const targets = [...document.querySelectorAll('a,button,[role=button],input,select,summary,[role=radio],[role=tab]')].filter(vis); const small = mobile ? targets .filter((el) => { const r = el.getBoundingClientRect(); 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 shortH = mobile ? targets .filter((el) => el.getBoundingClientRect().height < 44 && !el.closest('svg')) .map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 28)}" h=${Math.round(el.getBoundingClientRect().height)}`) : []; // Mid-word truncation: elements with text-overflow: ellipsis whose content is clipped. const clipped = [...document.querySelectorAll('body *')] .filter((el) => { const cs = getComputedStyle(el); if (cs.textOverflow !== 'ellipsis' || cs.overflow === 'visible') return false; return el.scrollWidth > el.clientWidth + 1 && el.textContent && el.textContent.trim().length > 0; }) .map((el) => `${el.tagName.toLowerCase()}: "${el.textContent.trim().slice(0, 40)}" ${el.clientWidth}/${el.scrollWidth}`); const text = document.body.innerText || ''; const bad = []; for (const re of [/\bundefined\b/g, /\bNaN\b/g, /(? tx.getBoundingClientRect()); for (let i = 0; i < ts.length; i++) for (let j = i + 1; j < ts.length; j++) { const a = ts[i]; const b = ts[j]; if (a.width && b.width && a.left < b.right - 1 && b.left < a.right - 1 && a.top < b.bottom - 1 && b.top < a.bottom - 1) overlapping.push(`${Math.round(a.left)},${Math.round(a.top)}`); } } return { overflow, wide, small: small.slice(0, 10), nSmall: small.length, shortH: shortH.slice(0, 8), nShortH: shortH.length, clipped: clipped.slice(0, 10), nClipped: clipped.length, bad, credits, mail, mac, cls: window.__cls, clsAfter: window.__clsAfterSettle, docH: de.scrollHeight, title: document.title, overlapping: overlapping.slice(0, 5), width }; }, { mobile, width }, ); } catch (e) { report.push({ path, width, theme, status: 'ERR', error: String(e).slice(0, 200), errors: errors.splice(0) }); continue; } const file = `${slug(path)}-${width}-${theme}.png`; await page.screenshot({ path: OUT + file, fullPage: !searchQ }).catch(() => {}); report.push({ path, width, theme, status, ...m, errors: errors.splice(0), file }); writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2)); } await ctx.close(); } } await browser.close(); writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2)); let fails = 0; for (const r of report) { const flags = []; if (r.status !== 200) flags.push(`HTTP ${r.status}`); if (r.overflow > 0) flags.push(`OVERFLOW +${r.overflow}px`); if (r.nSmall) flags.push(`${r.nSmall} small targets`); if (r.nShortH) flags.push(`${r.nShortH} targets <44h`); if (r.nClipped) flags.push(`${r.nClipped} clipped`); if (r.bad?.length) flags.push(`BAD TEXT ${r.bad.length}`); if (r.errors?.length) flags.push(`${r.errors.length} console errors`); if (r.clsAfter > 0.02) flags.push(`CLS-after ${r.clsAfter.toFixed(3)}`); if (r.cls > 0.1) flags.push(`CLS ${r.cls.toFixed(3)}`); if (r.credits === false || r.mail === false || r.mac === false) flags.push('NO CREDITS'); if (r.overlapping?.length) flags.push(`${r.overlapping.length} tick overlaps`); if (flags.length) fails++; console.log(`${String(r.width).padStart(4)} ${r.theme.padEnd(5)} ${r.path.padEnd(52)} ${flags.join(' · ') || 'ok'} (h=${r.docH})`); if (r.wide?.length) console.log(' wide:', r.wide.join(' | ')); if (r.small?.length) console.log(' small:', r.small.slice(0, 5).join(' | ')); if (r.shortH?.length) console.log(' <44h:', r.shortH.slice(0, 5).join(' | ')); if (r.clipped?.length) console.log(' clipped:', r.clipped.slice(0, 5).join(' | ')); if (r.bad?.length) console.log(' bad:', r.bad.join(' | ')); if (r.errors?.length) console.log(' errors:', r.errors.slice(0, 3).join(' | ')); } console.log(`\n${report.length} renders, ${fails} with flags`);