/** * QA sweep: key routes at 390 and 1440 px, dark and light — HTTP status, console errors, horizontal overflow, screenshot. * The shell pages (`/`, `/about`, `/watchlist`, and `/` with the command palette open) are also swept at * 320 · 360 · 390 · 430 · 768 · 1366 · 1440 · 1920 in both themes, with a touch-target check (≥ 44 px) on interactive elements. * Also asserts that the homepage counters match GET /api/v1/stats. * Run: node qa/screens.mjs [BASE_URL] [API_URL] (defaults http://localhost:8330, http://127.0.0.1:8331) */ import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs'; import { mkdirSync } from 'node:fs'; const BASE = process.argv[2] ?? 'http://localhost:8330'; const API = process.argv[3] ?? 'http://127.0.0.1:8331'; const OUT = new URL('./screens/', import.meta.url).pathname; mkdirSync(OUT, { recursive: true }); const 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', '/watchlist', '/developers', '/bot', '/models/does-not-exist', '/does-not-exist']; const WIDTHS = [390, 1440]; const THEMES = ['dark', 'light']; /** Shell sweep (D0): every width in the spec, both themes, palette open as a fourth "page". */ const SHELL_PAGES = ['/', '/about', '/watchlist', '/#palette']; const SHELL_WIDTHS = [320, 360, 390, 430, 768, 1366, 1440, 1920]; const fmt0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); /** `SHELL_ONLY=1 node qa/screens.mjs` skips the route sweep and runs the shell sweep + counters check only (~2 min). */ const SHELL_ONLY = process.env.SHELL_ONLY === '1'; const browser = await chromium.launch(); let failures = 0; const filterErrors = (errors, expected) => 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))); /** * Interactive *controls* smaller than 44 × 44 CSS px: buttons, inputs, selects, icon links and nav links. Inline text links inside * content (feed rows, prose, tables, key–value, footers) are exempt — they are text, not controls. When a modal (`[data-palette]`, * `[role="dialog"]`) is open only its own controls are measured, since the page behind it is inert. */ const smallTargets = () => { const root = document.querySelector('[data-palette], [role="dialog"]') ?? document; return [...root.querySelectorAll('button, [role="button"], input, select, a[href]')] .filter((el) => { const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) return false; const cs = getComputedStyle(el); if (cs.visibility === 'hidden' || cs.display === 'none') return false; if (el.closest('.sr-only, p, dd, .prose-atlas, td, .kv, [data-ticker], .data-table, footer')) return false; if (el.tagName === 'A') { // Text links outside navigation (feed rows, list items, headings) are content, not controls; icon-only links are controls. const inNav = !!el.closest('nav, [role="menu"], [role="listbox"]'); const textLink = el.textContent.trim().length > 0; if (!inNav && textLink) return false; } return Math.min(r.width, r.height) < 44; }) .map((el) => `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}.${[...el.classList].slice(0, 2).join('.')} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 24)}" ${Math.round(el.getBoundingClientRect().width)}×${Math.round(el.getBoundingClientRect().height)}`); }; for (const theme of SHELL_ONLY ? [] : 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 }); await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme); const page = await ctx.newPage(); for (const path of PAGES) { if (theme === 'light' && !['/', '/models', '/models/claude-opus-5', '/changes', '/search?q=claude', '/companies/anthropic', '/about', '/watchlist'].includes(path)) continue; const errors = []; const onErr = (e) => errors.push(String(e)); const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); }; page.on('pageerror', onErr); page.on('console', onCon); const t0 = Date.now(); const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 60000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` })); await page.waitForTimeout(600); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1); const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null); const expected = /does-not-exist/.test(path) ? 404 : 200; const status = res.status(); const filtered = filterErrors(errors, expected); const ok = status === expected && overflow <= 0 && filtered.length === 0 && applied === theme; if (!ok) failures++; 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) : ''}`); await page.screenshot({ path: `${OUT}${theme}-${width}-${path.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'}.png`, fullPage: false }).catch(() => undefined); page.off('pageerror', onErr); page.off('console', onCon); } await ctx.close(); } } // ---------------------------------------------------------------------------------------------------------- shell sweep console.log('\n— shell sweep (320 … 1920, dark + light, palette open) —'); for (const theme of THEMES) { for (const width of SHELL_WIDTHS) { const mobile = width < 768; const ctx = await browser.newContext({ viewport: { width, height: mobile ? 800 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme }); await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme); const page = await ctx.newPage(); for (const p of SHELL_PAGES) { const palette = p === '/#palette'; const path = palette ? '/' : p; const errors = []; const onErr = (e) => errors.push(String(e)); const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); }; page.on('pageerror', onErr); page.on('console', onCon); const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 60000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` })); if (palette) { await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k'); await page.waitForSelector('[data-palette-input]', { timeout: 5000 }).catch(() => undefined); await page.waitForTimeout(300); } else await page.waitForTimeout(400); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1); const small = mobile ? await page.evaluate(smallTargets).catch(() => []) : []; const filtered = filterErrors(errors, 200); const ok = res.status() === 200 && overflow <= 0 && filtered.length === 0 && small.length === 0; if (!ok) failures++; console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${String(width).padStart(4)} ${res.status()} overflow=${overflow} errors=${filtered.length} small-targets=${small.length} ${p}${small.length ? ' :: ' + small.slice(0, 3).join(' | ') : ''}${filtered.length ? ' :: ' + filtered[0].slice(0, 120) : ''}`); await page.screenshot({ path: `${OUT}shell-${theme}-${width}-${p.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'}.png`, fullPage: false }).catch(() => undefined); if (palette) await page.keyboard.press('Escape'); page.off('pageerror', onErr); page.off('console', onCon); } await ctx.close(); } } // Homepage counters must come from /api/v1/stats (never hardcoded). The homepage is ISR-cached for 60 s while the API may be // ingesting, so a counter is accepted when it lies between (stats before − growth over 60 s) and (stats after the page load): // the tolerance is derived from the observed ingestion rate and is zero when the database is static. try { const pick = (s) => ({ models: s.entities?.model, 'benchmark results': s.benchmark_results, documents: s.documents, papers: s.entities?.paper, providers: s.entities?.provider, datasets: s.entities?.dataset, 'current prices': s.prices_current }); const t0 = Date.now(); const before = pick(await (await fetch(`${API}/api/v1/stats`)).json()); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); await page.goto(BASE + '/', { waitUntil: 'networkidle' }); const text = await page.evaluate(() => document.body.innerText); await ctx.close(); const after = pick(await (await fetch(`${API}/api/v1/stats`)).json()); const elapsed = Math.max(1, (Date.now() - t0) / 1000); const nums = [...text.matchAll(/\d{1,3}(?:,\d{3})*/g)].map((m) => Number(m[0].replace(/,/g, ''))); for (const label of Object.keys(before)) { const a = Number(before[label]); const b = Number(after[label]); if (!Number.isFinite(a) || !Number.isFinite(b)) continue; const rate = Math.max(0, b - a) / elapsed; const lo = a - Math.ceil(rate * 60); const hit = nums.some((n) => n >= lo && n <= b); if (!hit) failures++; 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'}`); } } catch (e) { console.log(`SKIP counters check: API unreachable (${e.message})`); } await browser.close(); console.log(failures ? `\n${failures} failure(s)` : '\nall checks OK'); process.exit(failures ? 1 : 0);