SPB Git

spb/ai-atlas

Public
5.7 KB · 87 lines javascript
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
Files · 9f1db171c786cf4a3b99436329f29b145c02aeec
Menu

Theme