SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
10.3 KB · 158 lines javascript
Raw Blame History
1/**2 * QA sweep: key routes at 390 and 1440 px, dark and light — HTTP status, console errors, horizontal overflow, screenshot.3 * The shell pages (`/`, `/about`, `/watchlist`, and `/` with the command palette open) are also swept at4 * 320 · 360 · 390 · 430 · 768 · 1366 · 1440 · 1920 in both themes, with a touch-target check (≥ 44 px) on interactive elements.5 * Also asserts that the homepage counters match GET /api/v1/stats.6 * Run: node qa/screens.mjs [BASE_URL] [API_URL]   (defaults http://localhost:8330, http://127.0.0.1:8331)7 */8import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';9import { mkdirSync } from 'node:fs';1011const BASE = process.argv[2] ?? 'http://localhost:8330';12const API = process.argv[3] ?? 'http://127.0.0.1:8331';13const OUT = new URL('./screens/', import.meta.url).pathname;14mkdirSync(OUT, { recursive: true });1516const 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'];17const WIDTHS = [390, 1440];18const THEMES = ['dark', 'light'];19/** Shell sweep (D0): every width in the spec, both themes, palette open as a fourth "page". */20const SHELL_PAGES = ['/', '/about', '/watchlist', '/#palette'];21const SHELL_WIDTHS = [320, 360, 390, 430, 768, 1366, 1440, 1920];22const fmt0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });2324/** `SHELL_ONLY=1 node qa/screens.mjs` skips the route sweep and runs the shell sweep + counters check only (~2 min). */25const SHELL_ONLY = process.env.SHELL_ONLY === '1';2627const browser = await chromium.launch();28let failures = 0;2930const 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)));3132/**33 * Interactive *controls* smaller than 44 × 44 CSS px: buttons, inputs, selects, icon links and nav links. Inline text links inside34 * content (feed rows, prose, tables, key–value, footers) are exempt — they are text, not controls. When a modal (`[data-palette]`,35 * `[role="dialog"]`) is open only its own controls are measured, since the page behind it is inert.36 */37const smallTargets = () => {38  const root = document.querySelector('[data-palette], [role="dialog"]') ?? document;39  return [...root.querySelectorAll('button, [role="button"], input, select, a[href]')]40    .filter((el) => {41      const r = el.getBoundingClientRect();42      if (r.width === 0 || r.height === 0) return false;43      const cs = getComputedStyle(el);44      if (cs.visibility === 'hidden' || cs.display === 'none') return false;45      if (el.closest('.sr-only, p, dd, .prose-atlas, td, .kv, [data-ticker], .data-table, footer')) return false;46      if (el.tagName === 'A') {47        // Text links outside navigation (feed rows, list items, headings) are content, not controls; icon-only links are controls.48        const inNav = !!el.closest('nav, [role="menu"], [role="listbox"]');49        const textLink = el.textContent.trim().length > 0;50        if (!inNav && textLink) return false;51      }52      return Math.min(r.width, r.height) < 44;53    })54    .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)}`);55};5657for (const theme of SHELL_ONLY ? [] : THEMES) {58  for (const width of WIDTHS) {59    const mobile = width < 768;60    const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme });61    await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme);62    const page = await ctx.newPage();63    for (const path of PAGES) {64      if (theme === 'light' && !['/', '/models', '/models/claude-opus-5', '/changes', '/search?q=claude', '/companies/anthropic', '/about', '/watchlist'].includes(path)) continue;65      const errors = [];66      const onErr = (e) => errors.push(String(e));67      const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); };68      page.on('pageerror', onErr);69      page.on('console', onCon);70      const t0 = Date.now();71      const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 60000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` }));72      await page.waitForTimeout(600);73      const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1);74      const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null);75      const expected = /does-not-exist/.test(path) ? 404 : 200;76      const status = res.status();77      const filtered = filterErrors(errors, expected);78      const ok = status === expected && overflow <= 0 && filtered.length === 0 && applied === theme;79      if (!ok) failures++;80      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) : ''}`);81      await page.screenshot({ path: `${OUT}${theme}-${width}-${path.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'}.png`, fullPage: false }).catch(() => undefined);82      page.off('pageerror', onErr);83      page.off('console', onCon);84    }85    await ctx.close();86  }87}8889// ---------------------------------------------------------------------------------------------------------- shell sweep90console.log('\n— shell sweep (320 … 1920, dark + light, palette open) —');91for (const theme of THEMES) {92  for (const width of SHELL_WIDTHS) {93    const mobile = width < 768;94    const ctx = await browser.newContext({ viewport: { width, height: mobile ? 800 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme });95    await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme);96    const page = await ctx.newPage();97    for (const p of SHELL_PAGES) {98      const palette = p === '/#palette';99      const path = palette ? '/' : p;100      const errors = [];101      const onErr = (e) => errors.push(String(e));102      const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); };103      page.on('pageerror', onErr);104      page.on('console', onCon);105      const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 60000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` }));106      if (palette) {107        await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k');108        await page.waitForSelector('[data-palette-input]', { timeout: 5000 }).catch(() => undefined);109        await page.waitForTimeout(300);110      } else await page.waitForTimeout(400);111      const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1);112      const small = mobile ? await page.evaluate(smallTargets).catch(() => []) : [];113      const filtered = filterErrors(errors, 200);114      const ok = res.status() === 200 && overflow <= 0 && filtered.length === 0 && small.length === 0;115      if (!ok) failures++;116      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) : ''}`);117      await page.screenshot({ path: `${OUT}shell-${theme}-${width}-${p.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'}.png`, fullPage: false }).catch(() => undefined);118      if (palette) await page.keyboard.press('Escape');119      page.off('pageerror', onErr);120      page.off('console', onCon);121    }122    await ctx.close();123  }124}125126// Homepage counters must come from /api/v1/stats (never hardcoded). The homepage is ISR-cached for 60 s while the API may be127// ingesting, so a counter is accepted when it lies between (stats before − growth over 60 s) and (stats after the page load):128// the tolerance is derived from the observed ingestion rate and is zero when the database is static.129try {130  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 });131  const t0 = Date.now();132  const before = pick(await (await fetch(`${API}/api/v1/stats`)).json());133  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });134  const page = await ctx.newPage();135  await page.goto(BASE + '/', { waitUntil: 'networkidle' });136  const text = await page.evaluate(() => document.body.innerText);137  await ctx.close();138  const after = pick(await (await fetch(`${API}/api/v1/stats`)).json());139  const elapsed = Math.max(1, (Date.now() - t0) / 1000);140  const nums = [...text.matchAll(/\d{1,3}(?:,\d{3})*/g)].map((m) => Number(m[0].replace(/,/g, '')));141  for (const label of Object.keys(before)) {142    const a = Number(before[label]);143    const b = Number(after[label]);144    if (!Number.isFinite(a) || !Number.isFinite(b)) continue;145    const rate = Math.max(0, b - a) / elapsed;146    const lo = a - Math.ceil(rate * 60);147    const hit = nums.some((n) => n >= lo && n <= b);148    if (!hit) failures++;149    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'}`);150  }151} catch (e) {152  console.log(`SKIP counters check: API unreachable (${e.message})`);153}154155await browser.close();156console.log(failures ? `\n${failures} failure(s)` : '\nall checks OK');157process.exit(failures ? 1 : 0);158