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%
5.3 KB · 100 lines javascript
Raw Blame History
1/**2 * Platform pages QA: /stories, one story, /download, /updates, /api at 320/390/768/1440.3 * Checks: HTTP 200, no horizontal overflow, no console errors, tap targets ≥ 44 px on phones, no "undefined/NaN".4 * Screenshots → qa/screens/platform/<route>-<width>.png, report → qa/screens/platform/report.json.5 *   node qa/platform-qa.mjs [BASE_URL]6 */7import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';8import { mkdirSync, writeFileSync } from 'node:fs';910const BASE = process.argv[2] ?? process.env.BASE_URL ?? 'http://localhost:8290';11const OUT = new URL('./screens/platform/', import.meta.url).pathname;12mkdirSync(OUT, { recursive: true });13const ROUTES = ['/stories', '/stories/the-world-is-getting-older', '/stories/shifting-centre-of-the-world-economy', '/download', '/download?countries=canada,france&indicators=gdp,life-expectancy&from=2000', '/updates', '/api'];14const WIDTHS = [320, 390, 768, 1440];15const slug = (p) => p.slice(1).replace(/[/?=&,]+/g, '_');16const report = [];17const browser = await chromium.launch();18for (const width of WIDTHS) {19  const mobile = width < 768;20  const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile });21  const page = await ctx.newPage();22  const errors = [];23  page.on('pageerror', (e) => errors.push(`pageerror: ${String(e).slice(0, 200)}`));24  page.on('console', (m) => {25    if (m.type() === 'error') errors.push(m.text().slice(0, 200));26  });27  for (const path of ROUTES) {28    let status = 0;29    try {30      const resp = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90_000 });31      status = resp?.status() ?? 0;32    } catch (e) {33      report.push({ path, width, status: 'ERR', error: String(e).slice(0, 200) });34      continue;35    }36    await page.evaluate(() => document.fonts.ready);37    await page.evaluate(async () => {38      const h = document.documentElement.scrollHeight;39      for (let y = 0; y < h; y += 700) {40        window.scrollTo(0, y);41        await new Promise((r) => setTimeout(r, 50));42      }43      window.scrollTo(0, 0);44    });45    await page.waitForLoadState('networkidle').catch(() => {});46    await page.waitForTimeout(500);47    const m = await page.evaluate(48      ({ mobile }) => {49        const de = document.documentElement;50        const overflow = de.scrollWidth - de.clientWidth;51        const vis = (el) => {52          const r = el.getBoundingClientRect();53          if (r.width === 0 || r.height === 0) return false;54          const cs = getComputedStyle(el);55          return cs.visibility !== 'hidden' && cs.display !== 'none';56        };57        const wide = [...document.querySelectorAll('body *')]58          .filter((el) => el.getBoundingClientRect().right > de.clientWidth + 1 && el.getBoundingClientRect().width > 0)59          .slice(0, 5)60          .map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 3).join('.')} right=${Math.round(el.getBoundingClientRect().right)}`);61        const targets = [...document.querySelectorAll('a,button,[role=button],input,select,summary,[role=radio],[role=tab]')].filter(vis);62        const small = mobile ? targets.filter((el) => el.getBoundingClientRect().height < 44 && el.getBoundingClientRect().width < 44).map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30)}"`) : [];63        const text = document.body.innerText || '';64        const bad = [];65        for (const re of [/\bundefined\b/g, /\bNaN\b/g]) {66          let mm;67          while ((mm = re.exec(text))) bad.push(`${mm[0]} @ "${text.slice(Math.max(0, mm.index - 40), mm.index + 30).replace(/\s+/g, ' ')}"`);68        }69        const footer = document.querySelector('footer')?.innerText ?? '';70        const credits = /Simon-Pierre Boucher/.test(footer) && /contact@spboucher\.ai/.test(footer) && /MacLustr/.test(footer);71        return { overflow, wide, nSmall: small.length, small: small.slice(0, 6), bad, credits, title: document.title, h: de.scrollHeight };72      },73      { mobile },74    );75    const file = `${slug(path)}-${width}.png`;76    await page.screenshot({ path: OUT + file, fullPage: true }).catch(() => {});77    report.push({ path, width, status, ...m, errors: errors.splice(0), file });78  }79  await ctx.close();80}81await browser.close();82writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2));83let fails = 0;84for (const r of report) {85  const flags = [];86  if (r.status !== 200) flags.push(`HTTP ${r.status}`);87  if (r.overflow > 0) flags.push(`OVERFLOW +${r.overflow}px`);88  if (r.nSmall) flags.push(`${r.nSmall} small targets`);89  if (r.bad?.length) flags.push(`BAD TEXT ${r.bad.length}`);90  if (r.errors?.length) flags.push(`${r.errors.length} console errors`);91  if (r.credits === false) flags.push('NO CREDITS');92  if (flags.length) fails++;93  console.log(`${String(r.width).padStart(4)} ${r.path.padEnd(70)} ${flags.join(' · ') || 'ok'} (h=${r.h})`);94  if (r.wide?.length) console.log('       wide:', r.wide.join(' | '));95  if (r.small?.length) console.log('       small:', r.small.join(' | '));96  if (r.bad?.length) console.log('       bad:', r.bad.join(' | '));97  if (r.errors?.length) console.log('       errors:', r.errors.slice(0, 3).join(' | '));98}99console.log(`\n${report.length} renders, ${fails} with flags`);100