1/**2 * QA sweep: key routes at 390 and 1440 px, dark and light — HTTP status, console errors, horizontal overflow, screenshot.3 * Also asserts that the homepage counters match GET /api/v1/stats.4 * Run: node qa/screens.mjs [BASE_URL] [API_URL] (defaults http://localhost:8330, http://127.0.0.1:8331)5 */6import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';7import { mkdirSync } from 'node:fs';89const BASE = process.argv[2] ?? 'http://localhost:8330';10const API = process.argv[3] ?? 'http://127.0.0.1:8331';11const OUT = new URL('./screens/', import.meta.url).pathname;12mkdirSync(OUT, { recursive: true });1314const PAGES = ['/', '/search?q=claude', '/models', '/models?openness=open-weights&sort=params', '/models/claude-opus-5', '/companies', '/companies/anthropic', '/providers', '/providers/anthropic-2', '/benchmarks', '/benchmarks/tau-bench', '/hardware/nvidia-dgx-b200', '/hardware', '/papers', '/frameworks', '/datasets', '/tools', '/changes', `/changes/${new Date().toISOString().slice(0, 10)}`, '/timeline', '/compare', '/explore', '/methodology', '/sources', '/about', '/developers', '/bot', '/models/does-not-exist', '/does-not-exist'];15const WIDTHS = [390, 1440];16const THEMES = ['dark', 'light'];17const fmt0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });1819const browser = await chromium.launch();20let failures = 0;21for (const theme of THEMES) {22 for (const width of WIDTHS) {23 const mobile = width < 768;24 const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme });25 await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme);26 const page = await ctx.newPage();27 for (const path of PAGES) {28 if (theme === 'light' && !['/', '/models', '/models/claude-opus-5', '/changes', '/search?q=claude', '/companies/anthropic'].includes(path)) continue;29 const errors = [];30 const onErr = (e) => errors.push(String(e));31 const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); };32 page.on('pageerror', onErr);33 page.on('console', onCon);34 const t0 = Date.now();35 const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 60000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` }));36 await page.waitForTimeout(600);37 const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1);38 const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null);39 const expected = /does-not-exist/.test(path) ? 404 : 200;40 const status = res.status();41 // Dynamic-route 404s use Next's client-rendered error shell; React DEV warns about the inline theme <script> there42 // (verified absent in `next start`). Everything else must be clean.43 const filtered = errors.filter((e) => !/favicon|Failed to load resource: the server responded with a status of 404/.test(e)).filter((e) => !(expected === 404 && /Encountered a script tag while rendering React component/.test(e)));44 const ok = status === expected && overflow <= 0 && filtered.length === 0 && applied === theme;45 if (!ok) failures++;46 console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${width} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} theme=${applied} ${path}${filtered.length ? ' :: ' + filtered[0].slice(0, 140) : ''}`);47 await page.screenshot({ path: `${OUT}${theme}-${width}-${path.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'}.png`, fullPage: false }).catch(() => undefined);48 page.off('pageerror', onErr);49 page.off('console', onCon);50 }51 await ctx.close();52 }53}5455// Homepage counters must come from /api/v1/stats (never hardcoded). The homepage is ISR-cached for 60 s while the API may be56// ingesting, so a counter is accepted when it lies between (stats before − growth over 60 s) and (stats after the page load):57// the tolerance is derived from the observed ingestion rate and is zero when the database is static.58try {59 const pick = (s) => ({ models: s.entities?.model, 'change events': s.change_events, sources: s.sources, papers: s.entities?.paper, providers: s.entities?.provider, benchmarks: s.entities?.benchmark });60 const t0 = Date.now();61 const before = pick(await (await fetch(`${API}/api/v1/stats`)).json());62 const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });63 const page = await ctx.newPage();64 await page.goto(BASE + '/', { waitUntil: 'networkidle' });65 const text = await page.evaluate(() => document.body.innerText);66 await ctx.close();67 const after = pick(await (await fetch(`${API}/api/v1/stats`)).json());68 const elapsed = Math.max(1, (Date.now() - t0) / 1000);69 const nums = [...text.matchAll(/\d{1,3}(?:,\d{3})*/g)].map((m) => Number(m[0].replace(/,/g, '')));70 for (const label of Object.keys(before)) {71 const a = Number(before[label]);72 const b = Number(after[label]);73 if (!Number.isFinite(a) || !Number.isFinite(b)) continue;74 const rate = Math.max(0, b - a) / elapsed;75 const lo = a - Math.ceil(rate * 60);76 const hit = nums.some((n) => n >= lo && n <= b);77 if (!hit) failures++;78 console.log(`${hit ? 'OK ' : 'FAIL'} counter ${label}: page shows a value in [${fmt0.format(lo)}, ${fmt0.format(b)}] (stats ${fmt0.format(a)} → ${fmt0.format(b)} in ${elapsed.toFixed(0)} s)${hit ? '' : ' — NOT found'}`);79 }80} catch (e) {81 console.log(`SKIP counters check: API unreachable (${e.message})`);82}8384await browser.close();85console.log(failures ? `\n${failures} failure(s)` : '\nall checks OK');86process.exit(failures ? 1 : 0);87