spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1/**2 * Polish sweep (real API): every public route × 320/375/390/430/1280/1440 × light/dark.3 * Asserts: no horizontal overflow, tap targets ≥ 44 px on phones, no console errors, no layout shift after4 * fonts/charts settle (PerformanceObserver layout-shift, CLS > 0.02 flagged), no mid-word truncation at 3205 * (`.truncate` elements that actually clip), no "undefined"/"NaN"/"null" in the visible text, footer credits6 * present. Screenshots → qa/screens/polish/<route>-<width>-<theme>.png, report → qa/screens/polish/report.json.7 *8 * node qa/polish-sweep.mjs [BASE_URL] [--quick] (--quick: 390 + 1440 light only, 10 routes)9 */10import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';11import { mkdirSync, writeFileSync } from 'node:fs';1213const args = process.argv.slice(2);14const quick = args.includes('--quick');15const BASE = args.find((a) => a.startsWith('http')) ?? process.env.BASE_URL ?? 'http://localhost:8290';16const OUT = new URL('./screens/polish/', import.meta.url).pathname;17mkdirSync(OUT, { recursive: true });1819const ROUTES_FULL = [20 '/', '/countries', '/countries/canada', '/countries/canada/economy', '/countries/nigeria', '/countries/japan/energy',21 '/compare', '/compare/canada/united-states/france', '/compare/canada/united-states/france?tab=economy',22 '/rankings', '/rankings/gdp-per-capita', '/rankings/life-expectancy?group=oecd',23 '/indicators', '/indicators/life-expectancy', '/indicators/inflation',24 '/regions', '/regions/oecd', '/explore', '/changes', '/data', '/sources', '/sources/worldbank', '/methodology', '/api', '/?search=can',25];26const 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'];27const ROUTES = quick ? ROUTES_QUICK : ROUTES_FULL;28const WIDTHS = quick ? [390, 1440] : [320, 375, 390, 430, 1280, 1440];29const THEMES = quick ? ['light'] : ['light', 'dark'];3031const slug = (p) => (p === '/' ? 'home' : p.slice(1).replace(/[/?=&]+/g, '_'));32const report = [];33const browser = await chromium.launch();3435for (const theme of THEMES) {36 for (const width of WIDTHS) {37 const mobile = width < 768;38 const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme, reducedMotion: 'no-preference' });39 await ctx.addInitScript(() => {40 window.__cls = 0;41 window.__clsAfterSettle = 0;42 window.__settled = false;43 try {44 new PerformanceObserver((l) => {45 for (const e of l.getEntries()) {46 if (e.hadRecentInput) continue;47 window.__cls += e.value;48 if (window.__settled) window.__clsAfterSettle += e.value;49 }50 }).observe({ type: 'layout-shift', buffered: true });51 } catch {}52 });53 const page = await ctx.newPage();54 const errors = [];55 page.on('pageerror', (e) => errors.push(`pageerror: ${String(e).slice(0, 200)}`));56 page.on('console', (m) => {57 if (m.type() === 'error') errors.push(m.text().slice(0, 200));58 });59 for (const path of ROUTES) {60 let status = 0;61 try {62 const resp = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 60_000 });63 status = resp?.status() ?? 0;64 } catch (e) {65 report.push({ path, width, theme, status: 'ERR', error: String(e).slice(0, 200) });66 continue;67 }68 await page.evaluate(() => document.fonts.ready);69 await page.waitForTimeout(700);70 // "/?search=<q>" is not a route: open the search dialog from the header and type the query.71 const searchQ = /[?&]search=([^&]+)/.exec(path)?.[1];72 if (searchQ) {73 try {74 // The header renders two triggers (icon on phones, field on desktop): click the visible one.75 const trigger = page.locator('header button[aria-label="Open search"]').locator('visible=true').first();76 await trigger.click({ timeout: 10_000 });77 const input = page.locator('dialog input').first();78 await input.waitFor({ timeout: 10_000 });79 await input.fill(decodeURIComponent(searchQ));80 await page.waitForLoadState('networkidle').catch(() => {});81 await page.waitForTimeout(800);82 } catch (e) {83 errors.push(`search dialog: ${String(e).slice(0, 120)}`);84 }85 }86 // Scroll through the page so lazy charts mount, then back to top; measure shift after settle.87 if (!searchQ)88 await page.evaluate(async () => {89 const h = document.documentElement.scrollHeight;90 for (let y = 0; y < h; y += 700) {91 window.scrollTo(0, y);92 await new Promise((r) => setTimeout(r, 60));93 }94 window.scrollTo(0, 0);95 });96 await page.waitForLoadState('networkidle').catch(() => {});97 await page.waitForTimeout(500);98 await page.evaluate(() => {99 window.__settled = true;100 });101 await page.waitForTimeout(600);102 let m;103 try {104 m = await page.evaluate(105 ({ mobile, width }) => {106 const de = document.documentElement;107 const overflow = de.scrollWidth - de.clientWidth;108 const vis = (el) => {109 const r = el.getBoundingClientRect();110 if (r.width === 0 || r.height === 0) return false;111 const cs = getComputedStyle(el);112 return cs.visibility !== 'hidden' && cs.display !== 'none';113 };114 const wide = [...document.querySelectorAll('body *')]115 .filter((el) => {116 const r = el.getBoundingClientRect();117 return r.right > de.clientWidth + 1 && r.width > 0;118 })119 .slice(0, 6)120 .map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 3).join('.')} right=${Math.round(el.getBoundingClientRect().right)}`);121 const targets = [...document.querySelectorAll('a,button,[role=button],input,select,summary,[role=radio],[role=tab]')].filter(vis);122 const small = mobile123 ? targets124 .filter((el) => {125 const r = el.getBoundingClientRect();126 return r.height < 44 && r.width < 44;127 })128 .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)}`)129 : [];130 const shortH = mobile131 ? targets132 .filter((el) => el.getBoundingClientRect().height < 44 && !el.closest('svg'))133 .map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 28)}" h=${Math.round(el.getBoundingClientRect().height)}`)134 : [];135 // Mid-word truncation: elements with text-overflow: ellipsis whose content is clipped.136 const clipped = [...document.querySelectorAll('body *')]137 .filter((el) => {138 const cs = getComputedStyle(el);139 if (cs.textOverflow !== 'ellipsis' || cs.overflow === 'visible') return false;140 return el.scrollWidth > el.clientWidth + 1 && el.textContent && el.textContent.trim().length > 0;141 })142 .map((el) => `${el.tagName.toLowerCase()}: "${el.textContent.trim().slice(0, 40)}" ${el.clientWidth}/${el.scrollWidth}`);143 const text = document.body.innerText || '';144 const bad = [];145 for (const re of [/\bundefined\b/g, /\bNaN\b/g, /(?<![\w"':])null(?![\w"':])/g]) {146 let mm;147 let n = 0;148 while ((mm = re.exec(text)) && n < 3) {149 bad.push(`${mm[0]} @ "${text.slice(Math.max(0, mm.index - 40), mm.index + 30).replace(/\s+/g, ' ')}"`);150 n++;151 }152 }153 const footer = document.querySelector('footer');154 const ft = footer ? footer.innerText : '';155 const credits = /Made by\s+Simon-Pierre Boucher/.test(ft) && /contact@spboucher\.ai/.test(ft) && /Hosted on\s+MacLustr/.test(ft);156 const mail = !!footer?.querySelector('a[href="mailto:contact@spboucher.ai"]');157 const mac = !!footer?.querySelector('a[href="https://www.maclustr.io"]');158 const overlapping = [];159 // Chart tick labels overlapping each other (same axis group)160 for (const g of document.querySelectorAll('svg .axis')) {161 const ts = [...g.querySelectorAll('text')].map((tx) => tx.getBoundingClientRect());162 for (let i = 0; i < ts.length; i++)163 for (let j = i + 1; j < ts.length; j++) {164 const a = ts[i];165 const b = ts[j];166 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)}`);167 }168 }169 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 };170 },171 { mobile, width },172 );173 } catch (e) {174 report.push({ path, width, theme, status: 'ERR', error: String(e).slice(0, 200), errors: errors.splice(0) });175 continue;176 }177 const file = `${slug(path)}-${width}-${theme}.png`;178 await page.screenshot({ path: OUT + file, fullPage: !searchQ }).catch(() => {});179 report.push({ path, width, theme, status, ...m, errors: errors.splice(0), file });180 writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2));181 }182 await ctx.close();183 }184}185await browser.close();186writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2));187188let fails = 0;189for (const r of report) {190 const flags = [];191 if (r.status !== 200) flags.push(`HTTP ${r.status}`);192 if (r.overflow > 0) flags.push(`OVERFLOW +${r.overflow}px`);193 if (r.nSmall) flags.push(`${r.nSmall} small targets`);194 if (r.nShortH) flags.push(`${r.nShortH} targets <44h`);195 if (r.nClipped) flags.push(`${r.nClipped} clipped`);196 if (r.bad?.length) flags.push(`BAD TEXT ${r.bad.length}`);197 if (r.errors?.length) flags.push(`${r.errors.length} console errors`);198 if (r.clsAfter > 0.02) flags.push(`CLS-after ${r.clsAfter.toFixed(3)}`);199 if (r.cls > 0.1) flags.push(`CLS ${r.cls.toFixed(3)}`);200 if (r.credits === false || r.mail === false || r.mac === false) flags.push('NO CREDITS');201 if (r.overlapping?.length) flags.push(`${r.overlapping.length} tick overlaps`);202 if (flags.length) fails++;203 console.log(`${String(r.width).padStart(4)} ${r.theme.padEnd(5)} ${r.path.padEnd(52)} ${flags.join(' · ') || 'ok'} (h=${r.docH})`);204 if (r.wide?.length) console.log(' wide:', r.wide.join(' | '));205 if (r.small?.length) console.log(' small:', r.small.slice(0, 5).join(' | '));206 if (r.shortH?.length) console.log(' <44h:', r.shortH.slice(0, 5).join(' | '));207 if (r.clipped?.length) console.log(' clipped:', r.clipped.slice(0, 5).join(' | '));208 if (r.bad?.length) console.log(' bad:', r.bad.join(' | '));209 if (r.errors?.length) console.log(' errors:', r.errors.slice(0, 3).join(' | '));210}211console.log(`\n${report.length} renders, ${fails} with flags`);212