SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
10.3 KB · 207 lines javascript
Raw Blame History
1/**2 * QA sweep: every route at 390×844 (mobile) and 1440×900 (desktop), dark and light — HTTP status, console errors,3 * failed requests (404/5xx), horizontal overflow, small tap targets (mobile), screenshot into qa/screens/ (git-ignored).4 * Also checks that the homepage counters match GET /api/v1/stats and that the SSE feed prepends a row.5 * Run: node qa/screens.mjs [BASE_URL] [API_URL]   (defaults http://localhost:8370, http://127.0.0.1:8371)6 * Env: MOBILE_ONLY=1 · WIDTHS=360 · THEMES=dark · ONLY=/company/stripe7 */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:8370';12const API = process.argv[3] ?? 'http://127.0.0.1:8371';13const OUT = new URL('./screens/', import.meta.url).pathname;14mkdirSync(OUT, { recursive: true });1516async function j(path) {17  const r = await fetch(`${API}/api/v1${path}`);18  if (!r.ok) throw new Error(`${path} → ${r.status}`);19  return r.json();20}21// discover real ids from the API so provenance routes render22let ids = { event: null, sensor: null, snapshot: null, snapshot2: null, change: null };23try {24  const live = await j('/live?limit=5');25  const items = Array.isArray(live) ? live : live.items;26  const e = items.find((x) => x.change_id && x.sensor_id) ?? items[0];27  ids.event = e?.id ?? null;28  ids.change = e?.change_id ?? null;29  ids.sensor = e?.sensor_id ?? null;30  if (ids.sensor) {31    const snaps = await j(`/sensors/${ids.sensor}/snapshots?limit=2`);32    ids.snapshot = snaps.items?.[0]?.id ?? null;33    ids.snapshot2 = snaps.items?.[1]?.id ?? null;34  }35} catch (e) {36  console.log(`WARN could not discover ids from the API: ${e.message}`);37}3839const PAGES = [40  '/',41  '/live',42  '/live?event_type=PRICING&min_importance=0.5',43  '/companies',44  '/companies?country=CA&sort=hiring',45  '/company/stripe',46  '/company/stripe?tab=timeline',47  '/company/stripe?tab=timeline&filter=jobs',48  '/company/stripe?tab=signals',49  '/company/stripe?tab=jobs',50  '/company/stripe?tab=jobs&ai=1&status=removed',51  '/company/stripe?tab=products',52  '/company/stripe?tab=pricing',53  '/company/stripe?tab=locations',54  '/company/stripe?tab=leadership',55  '/company/stripe?tab=sources',56  '/company/stripe?tab=history',57  '/company/nvidia',58  '/company/sony',59  '/company/sony?tab=leadership',60  '/company/cohere',61  '/company/cabify',62  '/company/anthropic?tab=products',63  '/company/compass',64  '/company/compare?companies=stripe,adyen,block',65  '/company/compare',66  '/events',67  '/events?event_type=HIRING&min_confidence=0.7&sort=importance',68  ids.event ? `/events/${ids.event}` : null,69  ids.change ? `/change/${ids.change}` : null,70  ids.sensor ? `/sensor/${ids.sensor}` : null,71  ids.snapshot ? `/snapshot/${ids.snapshot}` : null,72  ids.snapshot && ids.snapshot2 ? `/snapshot/${ids.snapshot2}/diff/${ids.snapshot}` : null,73  '/rankings',74  '/rankings?kind=hiring_decline&window=7d',75  '/industry',76  '/industry/fintech',77  '/country',78  '/country/ca',79  '/search?q=stripe',80  '/search?q=companies%20hiring%20AI%20engineers%20in%20Canada',81  '/watchlist',82  '/system',83  '/about',84  '/methodology',85  '/api',86  '/bot',87  '/admin',88  '/company/does-not-exist',89  '/does-not-exist',90].filter(Boolean);91const ONLY = process.env.ONLY;92const pages = ONLY ? PAGES.filter((p) => p.startsWith(ONLY)) : PAGES;93const WIDTHS = process.env.WIDTHS ? process.env.WIDTHS.split(',').map(Number) : process.env.MOBILE_ONLY ? [390] : [390, 1440];94const THEMES = process.env.THEMES ? process.env.THEMES.split(',') : ['dark', 'light'];95const fmt0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });9697const browser = await chromium.launch();98let failures = 0;99const filterErrors = (errors, expected) => errors.filter((e) => !/favicon|the server responded with a status of 404 \(Not Found\)/.test(e) || expected !== 404).filter((e) => !/Encountered a script tag while rendering React component/.test(e));100101/** Interactive controls smaller than 44 px on mobile (text links in prose/tables/lists are exempt). */102const smallTargets = () => {103  const root = document.querySelector('[data-palette], [role="dialog"]') ?? document;104  return [...root.querySelectorAll('button, [role="button"], input, select, a[href]')]105    .filter((el) => {106      const r = el.getBoundingClientRect();107      if (r.width === 0 || r.height === 0) return false;108      const cs = getComputedStyle(el);109      if (cs.visibility === 'hidden' || cs.display === 'none') return false;110      if (el.closest('.sr-only, p, dd, dt, .prose-atlas, td, th, .kv, .data-table, footer, li, summary, h1, h2, h3, table, svg')) return false;111      if (el.classList.contains('chip-btn')) return false; // 32 px chips are intentionally compact and spaced112      if (el.tagName === 'A') {113        const inNav = !!el.closest('nav, [role="menu"], [role="listbox"]');114        if (!inNav && el.textContent.trim().length > 0) return false;115      }116      return Math.min(r.width, r.height) < 40;117    })118    .map((el) => `${el.tagName.toLowerCase()}.${[...el.classList].slice(0, 2).join('.')} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 22)}" ${Math.round(el.getBoundingClientRect().width)}×${Math.round(el.getBoundingClientRect().height)}`);119};120121for (const theme of THEMES) {122  for (const width of WIDTHS) {123    const mobile = width < 768;124    const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme });125    await ctx.addInitScript((t) => localStorage.setItem('ca-theme', t), theme);126    const page = await ctx.newPage();127    for (const path of pages) {128      const errors = [];129      const badReqs = [];130      const onErr = (e) => errors.push(String(e));131      const onCon = (m) => {132        if (m.type() === 'error') errors.push(m.text());133      };134      const onResp = (r) => {135        const s = r.status();136        if ((s === 404 || s >= 500) && !/favicon/.test(r.url())) badReqs.push(`${s} ${r.url().replace(BASE, '')}`);137      };138      page.on('pageerror', onErr);139      page.on('console', onCon);140      page.on('response', onResp);141      const t0 = Date.now();142      const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` }));143      await page.waitForTimeout(700);144      const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1);145      const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null);146      const small = mobile ? await page.evaluate(smallTargets).catch(() => []) : [];147      const expected = /does-not-exist/.test(path) ? 404 : 200;148      const status = res.status();149      const filtered = filterErrors(errors, expected);150      const bad = badReqs.filter((b) => !(expected === 404 && b.startsWith('404 ' + path)) && !/\/api\/v1\/(watchlist|alerts)/.test(b));151      const ok = status === expected && overflow <= 0 && filtered.length === 0 && bad.length === 0 && applied === theme && small.length === 0;152      if (!ok) failures++;153      console.log(`${ok ? 'OK  ' : 'FAIL'} ${theme.padEnd(5)} ${width} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} bad=${bad.length} small=${small.length} ${path}${filtered.length ? ' :: ' + filtered[0].slice(0, 160) : ''}${bad.length ? ' :: ' + bad.slice(0, 2).join(' | ') : ''}${small.length ? ' :: ' + small.slice(0, 3).join(' | ') : ''}`);154      await page.screenshot({ path: `${OUT}${theme}-${width}-${path.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'}.png`, fullPage: false }).catch(() => undefined);155      page.off('pageerror', onErr);156      page.off('console', onCon);157      page.off('response', onResp);158    }159    await ctx.close();160  }161}162163// counters must come from /api/v1/stats164if (!ONLY) {165  try {166    const t0 = Date.now();167    const before = await j('/stats');168    const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });169    const page = await ctx.newPage();170    await page.goto(BASE + '/', { waitUntil: 'networkidle' });171    await page.waitForTimeout(6000); // client refresh of /stats (1.5 s) + count-up; also long enough to measure the ingestion rate172    const text = await page.evaluate(() => document.querySelector('[data-live-counters]')?.innerText ?? '');173    const after = await j('/stats');174    const nums = [...text.matchAll(/\d{1,3}(?:,\d{3})*/g)].map((m) => Number(m[0].replace(/,/g, '')));175    // the page is ISR-cached (60 s) while the API keeps ingesting: accept values within 120 s of observed growth176    const elapsed = Math.max(1, (Date.now() - t0) / 1000);177    for (const k of ['companies', 'sensors', 'observations', 'changes', 'events']) {178      const rate = Math.max(0, after[k] - before[k]) / elapsed;179      const lo = Math.min(before[k], after[k]) - Math.ceil(rate * 120) - 5;180      const hi = Math.max(before[k], after[k]) + 5;181      const hit = nums.some((n) => n >= lo && n <= hi);182      if (!hit) failures++;183      console.log(`${hit ? 'OK  ' : 'FAIL'} counter ${k}: page shows a value in [${fmt0.format(lo)}, ${fmt0.format(hi)}]`);184    }185    // SSE: a new row should appear within ~12 s on /live186    await page.goto(BASE + '/live', { waitUntil: 'networkidle' });187    const first = await page.evaluate(() => document.querySelector('[data-live-feed] [data-event-id]')?.getAttribute('data-event-id'));188    await page.mouse.move(5, 5);189    let changed = false;190    for (let i = 0; i < 16 && !changed; i++) {191      await page.waitForTimeout(1000);192      changed = (await page.evaluate(() => document.querySelector('[data-live-feed] [data-event-id]')?.getAttribute('data-event-id'))) !== first;193    }194    if (!changed) failures++;195    const rows = await page.evaluate(() => document.querySelectorAll('[data-live-feed] [data-event-id]').length);196    const status = await page.evaluate(() => document.querySelector('[data-live-feed] .dot + span')?.textContent ?? '?');197    console.log(`${changed ? 'OK  ' : 'FAIL'} SSE live feed prepended a new event (rows=${rows}, status=${status})`);198    await ctx.close();199  } catch (e) {200    console.log(`SKIP counters/SSE check: ${e.message}`);201  }202}203204await browser.close();205console.log(failures ? `\n${failures} failure(s)` : '\nall checks OK');206process.exit(failures ? 1 : 0);207