/** * D3 QA sweep (temporal · graph · research · changes · search · explore · claims · admin · SEO). * Widths 320 · 360 · 375 · 390 · 430 · 768 · 1366 · 1440 · 1920, dark + light — HTTP status, zero console errors, no horizontal * overflow, screenshot — plus assertions: graph draws ≥ 2 nodes, compiled chips on /search, backfill honesty line on the digest, * admin login → /admin/quality → /admin/entity-resolution (token never in HTML), OG image routes, sitemap shards. * Run: node qa/d3.mjs [BASE_URL] [API_URL] [ADMIN_TOKEN] (defaults http://localhost:8343, http://127.0.0.1:8332, AIA_ADMIN_TOKEN from ../../.env) * QUICK=1 limits widths to 390 and 1440. */ import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs'; import { existsSync, mkdirSync, readFileSync } from 'node:fs'; const BASE = process.argv[2] ?? 'http://localhost:8343'; const API = process.argv[3] ?? 'http://127.0.0.1:8332'; function envToken() { for (const p of [new URL('../../../.env', import.meta.url).pathname, new URL('../.env', import.meta.url).pathname]) { if (!existsSync(p)) continue; const m = /^AIA_ADMIN_TOKEN=(.+)$/m.exec(readFileSync(p, 'utf8')); if (m) return m[1].trim().replace(/^["']|["']$/g, ''); } return null; } const TOKEN = process.argv[4] ?? process.env.AIA_ADMIN_TOKEN ?? envToken(); const OUT = new URL('./screens/d3/', import.meta.url).pathname; mkdirSync(OUT, { recursive: true }); const WIDTHS = process.env.QUICK === '1' ? [390, 1440] : [320, 360, 375, 390, 430, 768, 1366, 1440, 1920]; const THEMES = ['dark', 'light']; const today = new Date().toISOString().slice(0, 10); const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10); const j = async (path, headers = {}) => { const r = await fetch(`${API}/api/v1${path}`, { headers: { accept: 'application/json', ...headers } }); if (!r.ok) throw new Error(`API ${r.status} ${path}`); return r.json(); }; // ---- discover slugs live (nothing hardcoded) const trending = await j('/trending?kind=views&type=model&limit=1&days=7').catch(() => null); const models = await j('/models?limit=1&sort=quality'); const modelSlug = trending?.items?.[0]?.slug ?? models.items[0].slug; const papers = await j('/papers?limit=60&sort=updated'); const paperSlug = (papers.items.find((p) => (p.counts?.relations ?? 0) > 2) ?? papers.items[0]).slug; const orgs = await j('/companies?limit=1&sort=models'); const orgSlug = orgs.items.find((o) => o.slug === 'anthropic')?.slug ?? orgs.items[0].slug; const claims = await j(`/entities/${encodeURIComponent(modelSlug)}/claims?limit=1`); const claimId = claims.items[0]?.id; const researchers = await j('/explore/researcher?limit=1'); const researcherSlug = researchers.items[0]?.slug; let snapshotId = null; if (TOKEN) { try { const docs = await j('/admin/documents?limit=1', { 'x-aia-admin-token': TOKEN }); const doc = await j(`/admin/documents/${docs.items[0].id}`, { 'x-aia-admin-token': TOKEN }); snapshotId = Array.isArray(doc.snapshots) && doc.snapshots[0] ? doc.snapshots[0].id : null; } catch { snapshotId = null; } } console.log(`root model ${modelSlug} · paper ${paperSlug} · org ${orgSlug} · claim ${claimId} · researcher ${researcherSlug} · snapshot ${snapshotId ?? '—'} · token ${TOKEN ? 'yes' : 'NO'}`); const PAGES = [ { path: '/graph', check: 'graph' }, { path: `/graph?node=${orgSlug}&mode=company`, check: 'graph' }, { path: `/graph/${modelSlug}?mode=lineage`, check: 'graph' }, { path: '/timeline', check: 'timeline' }, { path: '/timeline?include_backfill=1&year=2025', check: 'timeline' }, { path: '/time-machine?date=2025-06-01', check: 'tm' }, { path: `/diff?a=${weekAgo}&b=${today}`, check: 'diff' }, { path: '/changes' }, { path: `/changes/${today}`, check: 'digest' }, { path: '/search?q=open+reasoning+models+over+30B+released+in+2026', check: 'search' }, { path: '/explore', check: 'builder' }, { path: '/papers' }, { path: `/papers/${paperSlug}` }, { path: '/companies' }, { path: `/companies/${orgSlug}` }, ...(researcherSlug ? [{ path: `/explore/researcher/${researcherSlug}` }] : []), ...(claimId ? [{ path: `/claims/${claimId}` }] : []), { path: '/sources' }, { path: '/methodology' }, { path: '/developers', check: 'builder-dev' }, { path: '/graph/does-not-exist-xyz', expected: 404 }, { path: '/claims/claim_does_not_exist', expected: 404 }, ]; const LIGHT_SUBSET = new Set(['/graph', `/graph/${modelSlug}?mode=lineage`, '/timeline', '/time-machine?date=2025-06-01', `/diff?a=${weekAgo}&b=${today}`, `/changes/${today}`, '/search?q=open+reasoning+models+over+30B+released+in+2026', '/explore', `/papers/${paperSlug}`, `/companies/${orgSlug}`, `/claims/${claimId}`, '/sources', '/methodology', '/developers']); const filterErrors = (errors, expected) => errors.filter((e) => !/favicon|Failed to load resource: the server responded with a status of 404|the server responded with a status of 429/.test(e)).filter((e) => !(expected === 404 && /Encountered a script tag while rendering React component/.test(e))); const browser = await chromium.launch(); let failures = 0; const slug = (p) => p.replace(/^\//, '').replace(/[^a-z0-9]+/gi, '_').slice(0, 60) || 'home'; async function checks(page, kind) { const problems = []; if (kind === 'graph') { await page.waitForSelector('[data-graph-canvas] [data-node]', { timeout: 20000 }).catch(() => undefined); const n = await page.locator('[data-graph-canvas] [data-node]').count(); if (n < 2) problems.push(`graph nodes=${n}`); if (!(await page.locator('[data-graph-count]').count())) problems.push('graph count line missing'); } if (kind === 'search') { const n = await page.locator('[data-compiled-chip]').count(); if (n < 1) problems.push(`compiled chips=${n}`); if (!(await page.locator('[data-open-builder]').count())) problems.push('builder link missing'); } if (kind === 'digest') { const text = await page.evaluate(() => document.body.innerText); if (!/occurred/i.test(text)) problems.push('digest lacks occurred caption'); } if (kind === 'tm') { if (!(await page.locator('[data-tm-banner]').count())) problems.push('time-machine banner missing'); const text = await page.evaluate(() => document.body.innerText); if (!/reconstructed/i.test(text)) problems.push('time-machine lacks reconstructed note'); } if (kind === 'diff') { const text = await page.evaluate(() => document.body.innerText); if (!/new models/i.test(text) || !/price changes/i.test(text)) problems.push('diff sections missing'); } if (kind === 'timeline') { if (!(await page.locator('[data-timeline-workbench]').count()) && !(await page.evaluate(() => /No events/i.test(document.body.innerText)))) problems.push('timeline workbench missing'); } if (kind === 'builder' && !(await page.locator('[data-query-builder]').count())) problems.push('query builder missing'); if (kind === 'builder-dev' && !(await page.locator('[data-request-builder]').count())) problems.push('request builder missing'); return problems; } for (const theme of 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, check, expected: exp } of PAGES) { if (theme === 'light' && !LIGHT_SUBSET.has(path)) continue; if (theme === 'light' && ![390, 1440, 320, 1920].includes(width)) continue; const errors = []; page.on('pageerror', (e) => errors.push(String(e))); page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); const t0 = Date.now(); const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` })); await page.waitForTimeout(700); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1); const expected = exp ?? 200; const status = res.status(); const filtered = filterErrors(errors, expected); const problems = status === 200 && check ? await checks(page, check) : []; const ok = status === expected && overflow <= 0 && filtered.length === 0 && problems.length === 0; if (!ok) failures++; console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${String(width).padStart(4)} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} ${path}${problems.length ? ' :: ' + problems.join('; ') : ''}${filtered.length ? ' :: ' + filtered[0].slice(0, 160) : ''}`); if ([320, 390, 1440, 1920].includes(width)) await page.screenshot({ path: `${OUT}${theme}-${width}-${slug(path)}.png`, fullPage: width >= 768 }).catch(() => undefined); page.removeAllListeners('pageerror'); page.removeAllListeners('console'); } await ctx.close(); } } // ---- flows async function flow(name, width, fn) { const ctx = await browser.newContext({ viewport: { width, height: width < 768 ? 844 : 900 }, colorScheme: 'dark', isMobile: width < 768, hasTouch: width < 768 }); await ctx.addInitScript(() => localStorage.setItem('aia-theme', 'dark')); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(String(e))); page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); try { await fn(page); const filtered = filterErrors(errors, 200); if (filtered.length) throw new Error(`console: ${filtered[0].slice(0, 160)}`); console.log(`OK flow ${name} @${width}`); } catch (e) { failures++; console.log(`FAIL flow ${name} @${width} :: ${e.message.slice(0, 200)}`); await page.screenshot({ path: `${OUT}flow-${name}-${width}-fail.png`, fullPage: true }).catch(() => undefined); } await ctx.close(); } for (const width of [390, 1440]) { // Graph: select a node → inspector shows it; expand loads more nodes (or reports already expanded). await flow('graph-inspect-expand', width, async (page) => { await page.goto(`${BASE}/graph/${modelSlug}?mode=lineage`, { waitUntil: 'networkidle' }); await page.waitForSelector('[data-graph-canvas] [data-node]', { timeout: 20000 }); const before = await page.locator('[data-graph-canvas] [data-node]').count(); const nodes = page.locator('[data-graph-canvas] [data-node]'); const target = nodes.nth(Math.min(1, before - 1)); await target.dispatchEvent('pointerdown', { pointerId: 1, clientX: 0, clientY: 0, isPrimary: true, button: 0 }); await target.dispatchEvent('pointerup', { pointerId: 1, clientX: 0, clientY: 0, isPrimary: true, button: 0 }); // Fallback: keyboard selection is always available. await target.focus(); await page.keyboard.press('Enter'); await page.waitForTimeout(400); const inspectors = await page.locator('[data-graph-inspector]').count(); if (inspectors < 1) throw new Error('inspector not rendered after selection'); const expand = page.locator('[data-graph-expand]:not([disabled]):visible').first(); if (await expand.count()) { await expand.click(); await page.waitForTimeout(2500); const after = await page.locator('[data-graph-canvas] [data-node]').count(); if (after < before) throw new Error(`nodes shrank ${before} → ${after}`); } await page.screenshot({ path: `${OUT}flow-graph-${width}.png` }); }); // Graph: switch mode via the rail (desktop) → count line updates without navigation error. if (width >= 1024) await flow('graph-mode-switch', width, async (page) => { await page.goto(`${BASE}/graph?node=${orgSlug}&mode=company`, { waitUntil: 'networkidle' }); await page.waitForSelector('[data-graph-canvas] [data-node]', { timeout: 20000 }); await page.locator('[data-graph-mode="provider"]').click(); await page.waitForTimeout(2500); if (!/mode=provider/.test(page.url())) throw new Error(`url not updated: ${page.url()}`); const text = await page.locator('[data-graph-count]').innerText(); if (!/nodes/.test(text)) throw new Error('count line missing after mode switch'); }); // Search: remove a compiled chip → re-query without its words. await flow('search-chip-remove', width, async (page) => { await page.goto(`${BASE}/search?q=open+reasoning+models+over+30B+released+in+2026`, { waitUntil: 'networkidle' }); const chips = await page.locator('[data-compiled-chip]').count(); if (chips < 2) throw new Error(`chips=${chips}`); const remove = page.locator('[data-compiled-chip] a[aria-label^="Remove"]').first(); const label = await remove.getAttribute('aria-label'); const before = page.url(); await Promise.all([page.waitForURL((u) => u.toString() !== before), remove.click()]); await page.waitForLoadState('networkidle'); const after = await page.locator('[data-compiled-chip]').count(); if (after >= chips) throw new Error(`chips not reduced after "${label}": ${chips} → ${after}`); }); // Explore builder: set a filter, run → /models URL with the param; save a query → appears in the list. await flow('explore-builder', width, async (page) => { await page.goto(`${BASE}/explore`, { waitUntil: 'networkidle' }); await page.locator('[data-query-builder] select').nth(2).selectOption({ index: 3 }); // Parameters ≥ 7B await page.waitForTimeout(300); const target = await page.locator('[data-target-url]').innerText(); if (!/\/models\?.*min_params=/.test(target)) throw new Error(`target url ${target}`); if (!/type=model/.test(page.url())) throw new Error(`share url not mirrored: ${page.url()}`); await page.locator('[data-save-query]').click(); await page.waitForTimeout(300); if ((await page.locator('[data-saved-queries] li').count()) < 1) throw new Error('saved query not listed'); await Promise.all([page.waitForURL(/\/models\?/), page.locator('[data-run-query]').click()]); }); // Time machine: presets navigate and the banner says reconstructed / observed. await flow('time-machine-presets', width, async (page) => { await page.goto(`${BASE}/time-machine`, { waitUntil: 'networkidle' }); await Promise.all([page.waitForURL(/date=2024-01-01/), page.locator('a[href*="date=2024-01-01"]').first().click()]); await page.waitForLoadState('networkidle'); const text = await page.locator('[data-tm-banner]').innerText(); if (!/reconstructed|observed/i.test(text)) throw new Error('banner missing basis'); await Promise.all([page.waitForURL(/scope=benchmarks/), page.locator('a[href*="scope=benchmarks"]').first().click()]); }); // Changes feed: load more via cursor. await flow('changes-load-more', width, async (page) => { await page.goto(`${BASE}/changes`, { waitUntil: 'networkidle' }); const before = await page.locator('[data-event-id]').count(); const btn = page.locator('[data-load-more]'); if (!(await btn.count())) return; // fewer than 50 events: nothing to page await btn.click(); await page.waitForTimeout(2500); const after = await page.locator('[data-event-id]').count(); if (after <= before) throw new Error(`load more did not append (${before} → ${after})`); }); // Developers: request builder → Try → status line. await flow('developers-try', width, async (page) => { await page.goto(`${BASE}/developers`, { waitUntil: 'networkidle' }); await page.locator('[data-route-picker]').selectOption('stats'); await page.locator('[data-try]').click(); await page.waitForSelector('[data-try-result] pre', { timeout: 20000 }); const text = await page.locator('[data-try-result]').innerText(); if (!/HTTP 200/.test(text)) throw new Error(`try result: ${text.slice(0, 80)}`); }); // Admin: login → quality → entity-resolution → anomalies → audit → extractions; token never in HTML. if (TOKEN) await flow('admin-workbenches', width, async (page) => { await page.goto(`${BASE}/admin`, { waitUntil: 'networkidle' }); if ((await page.content()).includes(TOKEN)) throw new Error('token leaked before login'); await page.locator('input[type="password"]').first().fill(TOKEN); await Promise.all([page.waitForURL(/\/admin\/(overview|connectors)/, { timeout: 30000 }), page.locator('form button[type="submit"]').first().click()]); const pages = ['/admin/quality', '/admin/entity-resolution', '/admin/anomalies', '/admin/quarantine', '/admin/audit', '/admin/runs', ...(snapshotId ? [`/admin/extractions/${snapshotId}`] : [])]; for (const p of pages) { const r = await page.goto(`${BASE}${p}`, { waitUntil: 'networkidle', timeout: 60000 }); if (r.status() !== 200) throw new Error(`${p} → ${r.status()}`); const html = await page.content(); if (html.includes(TOKEN)) throw new Error(`token leaked in ${p}`); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); if (overflow > 0) throw new Error(`${p} overflow ${overflow}`); if (p === '/admin/quality' && !(await page.locator('[data-quality-tiles] li').count())) throw new Error('quality tiles missing'); if (p === '/admin/entity-resolution' && !(await page.locator('[data-resolution-pair]').count()) && !/No candidate pairs/.test(await page.evaluate(() => document.body.innerText))) throw new Error('resolution pairs missing'); if (p === '/admin/runs' && !(await page.locator('[data-rollback]').count())) throw new Error('rollback buttons missing'); if (p.startsWith('/admin/extractions') && !(await page.locator('[data-extraction-text]').count())) throw new Error('extraction text missing'); await page.screenshot({ path: `${OUT}admin-${width}-${slug(p)}.png`, fullPage: width >= 768 }); } // Sources shows operator columns when the cookie exists (cookie path is /admin, so /sources must not see it → public view). await page.goto(`${BASE}/sources`, { waitUntil: 'networkidle' }); if ((await page.content()).includes(TOKEN)) throw new Error('token leaked in /sources'); // Rollback button opens a confirmation field, disabled until the id is typed. await page.goto(`${BASE}/admin/runs`, { waitUntil: 'networkidle' }); const rb = page.locator('[data-rollback]:not([disabled])').first(); if (await rb.count()) { await rb.click(); const confirm = page.locator('[data-rollback-form] button[type="submit"]'); if (!(await confirm.isDisabled())) throw new Error('rollback confirm enabled before typing the id'); } }); } // ---- OG images and sitemap (plain HTTP) for (const p of ['/graph/og', `/graph/og?node=${modelSlug}&mode=lineage`, '/time-machine/og?date=2025-06-01', `/diff/og?a=${weekAgo}&b=${today}`, `/changes/${today}/opengraph-image`, `/companies/${orgSlug}/opengraph-image`, `/papers/${paperSlug}/opengraph-image`]) { const r = await fetch(BASE + p); const ok = r.status === 200 && /image\/png/.test(r.headers.get('content-type') ?? ''); if (!ok) failures++; console.log(`${ok ? 'OK ' : 'FAIL'} og ${r.status} ${r.headers.get('content-type')} ${p}`); } { const idx = await (await fetch(`${BASE}/sitemap.xml`)).text(); const st = await (await fetch(`${BASE}/sitemap/static.xml`)).text(); const okIdx = /model_family-0\.xml/.test(idx) && /license-0\.xml/.test(idx) && /artifact-0\.xml/.test(idx) && /changes\.xml/.test(idx); const okSt = ['/time-machine', '/graph', '/frontier', '/pulse', '/families', '/licenses', '/agents', '/calculator', '/run-locally', '/find-a-model', '/open'].every((r) => st.includes(``) && st.includes(r)); if (!okIdx || !okSt) failures++; console.log(`${okIdx && okSt ? 'OK ' : 'FAIL'} sitemap index(model_family/license/artifact/changes)=${okIdx} static(new routes)=${okSt}`); const fam = await fetch(`${BASE}/sitemap/model_family-0.xml`); const famBody = await fam.text(); const okFam = fam.status === 200 && /\/families\//.test(famBody); if (!okFam) failures++; console.log(`${okFam ? 'OK ' : 'FAIL'} sitemap model_family-0 ${fam.status}`); } await browser.close(); console.log(failures ? `\n${failures} failure(s)` : '\nAll D3 checks passed'); process.exit(failures ? 1 : 0);