SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
11.4 KB · 212 lines javascript
Raw Blame History
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