#!/usr/bin/env node /** * CancerIndex web smoke suite — read-only. * * BASE_URL=http://127.0.0.1:8253 node qa/smoke.mjs (default) * BASE_URL=https://www.cancerindex.io node qa/smoke.mjs (production, read-only) * * Checks, for every route in ROUTES: HTTP 200, key text present, HTML weight under MAX_KB, no * "Data not yet available" on pages that must have data, and — when Playwright is available — no * horizontal overflow at 390 px plus a console-error scan. Playwright is resolved from a sibling * project's node_modules (never installed globally); without it the suite degrades to HTTP-only. * * Exit code 1 when any hard check fails. Prints a table. */ import { createRequire } from 'node:module'; import { existsSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; const BASE_URL = (process.env.BASE_URL ?? 'http://127.0.0.1:8253').replace(/\/$/, ''); const MAX_KB = Number(process.env.MAX_KB ?? 600); const TIMEOUT_MS = Number(process.env.TIMEOUT_MS ?? 30000); const MOBILE_WIDTH = 390; const ONLY_HTTP = process.env.HTTP_ONLY === '1'; /** @type {Array<{path: string, expect: string[], mustHaveData?: boolean, optionalLocal?: boolean, maxKb?: number, resolve?: (ctx: Record) => string | null, kind?: 'html'|'xml'|'json'}>} */ const ROUTES = [ { path: '/', expect: ['CancerIndex'], maxKb: 800 }, { path: '/cancers', expect: ['Cancers'] }, { path: '/cancer/malignant-pancreatic-neoplasm', expect: ['Malignant Pancreatic Neoplasm', 'CI-CAN-'] }, // "observations" only appears in the data branch (Freshness "N observations"); the empty branch is caught by mustHaveData. { path: '/cancer/malignant-pancreatic-neoplasm/statistics', expect: ['Population statistics', 'observations'], mustHaveData: true }, { path: '/cancer/malignant-pancreatic-neoplasm/trials', expect: ['Registered studies', 'NCT'] }, { path: '/cancer/malignant-pancreatic-neoplasm/evidence', expect: ['Curated clinical evidence', 'civic'] }, { path: '/cancer/malignant-pancreatic-neoplasm/rankings', expect: ['Rankings'] }, { path: '/gene/TP53', expect: ['TP53', 'Clinical evidence'] }, { path: '/variant/braf-v600e', expect: ['V600E', 'Evidence by cancer'] }, { path: '/drug/osimertinib', expect: ['Osimertinib', 'Approvals'], resolve: (ctx) => ctx.drug ?? '/drug/osimertinib' }, { path: '/trial/', expect: ['NCT', 'Conditions'], resolve: (ctx) => ctx.trial ?? null }, { path: '/rankings', expect: ['Rankings', 'metric'] }, { path: '/rankings/trial_gap_ratio', expect: ['Trial Gap Ratio', 'rank'] }, { path: '/explore', expect: ['Data explorer', 'CSV'], mustHaveData: true }, { path: '/explore/coverage', expect: ['Coverage'] }, { path: '/trials/intelligence', expect: ['intelligence', 'HHI'], mustHaveData: true, maxKb: 900 }, { path: '/trials/terminated', expect: ['NCT'] , maxKb: 900 }, { path: '/trials/map', expect: ['Trial map', 'ISO'], maxKb: 1200 }, { path: '/research-gap', expect: ['Research Gap', 'log'], mustHaveData: true, maxKb: 900 }, { path: '/graph', expect: ['graph', 'CI-CAN-'], maxKb: 1100 }, { path: '/graph?focus=gene:KRAS', expect: ['KRAS'], maxKb: 900 }, { path: '/approvals', expect: ['approvals', 'FDA'], maxKb: 900 }, { path: '/pipeline', expect: ['pipeline', 'Phase'], maxKb: 900 }, { path: '/methodology/trial-map', expect: ['ISO'] }, { path: '/pulse', expect: ['What changed in cancer', 'Phase III'] }, { path: '/biomarkers', expect: ['Biomarkers', 'HER2'] }, { path: '/biomarker/braf-v600e', expect: ['BRAF', 'NCIt'], maxKb: 900 }, { path: '/api/v1/biomarkers?limit=3', expect: ['data'], kind: 'json', optionalLocal: true }, { path: '/year/2025', expect: ['2025 in cancer', 'Phase III'] }, { path: '/country/canada', expect: ['Clinical trial activity in Canada', 'Oncology approval records'] }, { path: '/data-updates', expect: ['Data update log', 'ING-'] }, { path: '/api/v1/research-gap', expect: ['data'], kind: 'json', optionalLocal: true }, { path: '/api/v1/trials/intelligence?limit=2', expect: ['data'], kind: 'json', optionalLocal: true }, { path: '/api/v1/epidemiology/metrics', expect: ['data'], kind: 'json', optionalLocal: true }, { path: '/api/v1/approvals/recent?days=365&limit=5', expect: ['data'], kind: 'json', optionalLocal: true }, { path: '/api/v1/graph/gene/TP53?limit=5', expect: ['data'], kind: 'json', optionalLocal: true }, { path: '/rankings/mortality_count', expect: ['mortality', 'rank'] }, { path: '/taxonomy', expect: ['Taxonomy'] }, { path: '/sources', expect: ['Sources', 'license'] }, { path: '/source/ncit-evs', expect: ['NCI', 'ncit-evs'] }, { path: '/methodology', expect: ['Methodology'] }, { path: '/search?q=glio', expect: ['Results for', 'glio'] }, { path: '/sitemap.xml', expect: [''], kind: 'xml' }, { path: '/api/v1/stats', expect: ['data'], kind: 'json', optionalLocal: true }, ]; const c = { green: (s) => `\x1b[32m${s}\x1b[0m`, red: (s) => `\x1b[31m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m` }; const isLocal = /^(https?:\/\/)?(127\.0\.0\.1|localhost|0\.0\.0\.0)(:|\/|$)/.test(BASE_URL); async function fetchText(url) { const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), TIMEOUT_MS); const started = performance.now(); try { const res = await fetch(url, { signal: ctl.signal, redirect: 'follow', headers: { 'user-agent': 'cancerindex-smoke/1.0', accept: 'text/html,application/xml,application/json;q=0.9,*/*;q=0.8' } }); const buf = Buffer.from(await res.arrayBuffer()); return { status: res.status, bytes: buf.byteLength, text: buf.toString('utf8'), ms: Math.round(performance.now() - started), headers: res.headers, finalUrl: res.url }; } finally { clearTimeout(t); } } /** Discover a real drug slug and NCT id from the running site (no DB access from the QA suite). */ async function discover() { const ctx = {}; try { const d = await fetchText(`${BASE_URL}/drug/osimertinib`); if (d.status === 200) ctx.drug = '/drug/osimertinib'; else { const list = await fetchText(`${BASE_URL}/drugs`); const m = /href="(\/drug\/[a-z0-9-]+)"/.exec(list.text); if (m) ctx.drug = m[1]; } } catch {} try { const list = await fetchText(`${BASE_URL}/trials`); const m = /href="(\/trial\/NCT\d{8})"/.exec(list.text); if (m) ctx.trial = m[1]; } catch {} return ctx; } async function loadPlaywright() { if (ONLY_HTTP) return null; const candidates = [ process.env.PLAYWRIGHT_DIR, path.join(os.homedir(), 'Desktop/Projets/apps-web/fetcha/qa'), path.join(os.homedir(), 'Desktop/Projets/apps-web/rareindex'), path.join(os.homedir(), 'Desktop/Projets/apps-web/fetcha'), process.cwd(), ].filter(Boolean); for (const dir of candidates) { const pkg = path.join(dir, 'node_modules', 'playwright', 'package.json'); if (!existsSync(pkg)) continue; try { const req = createRequire(path.join(dir, 'package.json')); const pw = req('playwright'); const browser = await pw.chromium.launch({ headless: true }); return { pw, browser, from: dir }; } catch (err) { console.error(c.dim(`playwright at ${dir} unusable: ${err.message.split('\n')[0]}`)); } } return null; } function pad(s, n, right = false) { s = String(s); if (s.length >= n) return s.slice(0, n); return right ? ' '.repeat(n - s.length) + s : s + ' '.repeat(n - s.length); } async function main() { console.log(`CancerIndex smoke · ${BASE_URL} · max ${MAX_KB} KB · viewport ${MOBILE_WIDTH}px`); const ctx = await discover(); const pwr = await loadPlaywright(); if (pwr) console.log(c.dim(`Playwright from ${pwr.from}`)); else console.log(c.yellow('Playwright unavailable — HTTP-only mode (no overflow / console checks)')); const rows = []; let failures = 0; let warnings = 0; const page = pwr ? await pwr.browser.newPage({ viewport: { width: MOBILE_WIDTH, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }) : null; for (const r of ROUTES) { const p = r.resolve ? r.resolve(ctx) : r.path; if (!p) { rows.push({ path: r.path, status: '—', kb: '—', ms: '—', overflow: '—', result: c.yellow('SKIP'), note: 'could not discover an instance' }); warnings++; continue; } const url = `${BASE_URL}${p}`; const problems = []; const notes = []; let res; try { res = await fetchText(url); } catch (err) { rows.push({ path: p, status: 'ERR', kb: '—', ms: '—', overflow: '—', result: c.red('FAIL'), note: err.message }); failures++; continue; } const kb = Math.round(res.bytes / 1024); const optional = r.optionalLocal && isLocal; if (res.status !== 200) { if (optional && (res.status === 500 || res.status === 502 || res.status === 503)) notes.push(`API proxy target down locally (${res.status}) — optional`); else problems.push(`status ${res.status}`); } else { const lower = res.text.toLowerCase(); for (const key of r.expect) if (!lower.includes(key.toLowerCase())) problems.push(`missing text "${key}"`); if (r.kind !== 'json' && r.kind !== 'xml') { if (kb > (r.maxKb ?? MAX_KB)) problems.push(`weight ${kb} KB > ${r.maxKb ?? MAX_KB} KB`); if (r.mustHaveData && lower.includes('data not yet available')) problems.push('"Data not yet available" on a page that must have data'); if (/application error|internal server error|something went wrong rendering/i.test(res.text)) problems.push('error boundary rendered'); } if (r.kind === 'json') { try { const j = JSON.parse(res.text); if (!j || typeof j !== 'object' || !('data' in j)) problems.push('JSON envelope missing "data"'); } catch { problems.push('invalid JSON'); } } const cc = res.headers.get('cache-control') ?? ''; if (/^\/(cancer|gene|drug|trial|source)\//.test(p) || /^\/rankings/.test(p)) { if (!/s-maxage/.test(cc)) notes.push(`no s-maxage (cache-control: ${cc || 'none'})`); } } let overflow = '—'; if (page && res.status === 200 && r.kind !== 'json' && r.kind !== 'xml') { const consoleErrors = []; const onConsole = (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); }; page.on('console', onConsole); try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS }); await page.waitForTimeout(250); const m = await page.evaluate(() => { const doc = document.documentElement; const bodyOverflow = Math.max(doc.scrollWidth, document.body.scrollWidth) - doc.clientWidth; // Elements wider than the viewport that are not inside a horizontally scrollable wrapper. const offenders = []; const vw = doc.clientWidth; for (const el of document.querySelectorAll('body *')) { const rect = el.getBoundingClientRect(); if (rect.right > vw + 1 && rect.width > 0) { let scrollable = false; for (let a = el.parentElement; a; a = a.parentElement) { const ov = getComputedStyle(a).overflowX; if (ov === 'auto' || ov === 'scroll' || ov === 'hidden' || ov === 'clip') { scrollable = true; break; } } if (!scrollable && el.tagName !== 'HTML' && el.tagName !== 'BODY') offenders.push(`${el.tagName.toLowerCase()}${el.className && typeof el.className === 'string' ? '.' + el.className.split(' ').slice(0, 2).join('.') : ''} ${Math.round(rect.right - vw)}px`); if (offenders.length >= 3) break; } } return { bodyOverflow, offenders }; }); overflow = m.bodyOverflow > 1 ? `${m.bodyOverflow}px` : 'ok'; if (m.bodyOverflow > 1) problems.push(`horizontal overflow ${m.bodyOverflow}px at ${MOBILE_WIDTH}px${m.offenders.length ? ` (${m.offenders.join('; ')})` : ''}`); const realErrors = consoleErrors.filter((t) => !/favicon|net::ERR_|Failed to load resource|hydrat/i.test(t)); if (realErrors.length) notes.push(`${realErrors.length} console error(s): ${realErrors[0].slice(0, 90)}`); } catch (err) { notes.push(`browser: ${err.message.split('\n')[0].slice(0, 80)}`); } finally { page.off('console', onConsole); } } const ok = problems.length === 0; if (!ok) failures++; if (notes.length) warnings++; rows.push({ path: p, status: res.status, kb, ms: res.ms, overflow, result: ok ? c.green('PASS') : c.red('FAIL'), note: [...problems, ...notes].join(' · ') }); } if (pwr) await pwr.browser.close(); console.log(''); console.log(`${pad('Route', 52)} ${pad('Status', 6, true)} ${pad('KB', 6, true)} ${pad('ms', 6, true)} ${pad('390px', 7)} Result Notes`); console.log('-'.repeat(120)); for (const r of rows) console.log(`${pad(r.path, 52)} ${pad(r.status, 6, true)} ${pad(r.kb, 6, true)} ${pad(r.ms, 6, true)} ${pad(r.overflow, 7)} ${r.result} ${r.note ? c.dim(r.note) : ''}`); console.log('-'.repeat(120)); const passed = rows.filter((r) => r.result.includes('PASS')).length; console.log(`${passed}/${rows.length} routes passed · ${failures} failure(s) · ${warnings} warning(s) · ${BASE_URL}`); process.exit(failures ? 1 : 0); } main().catch((err) => { console.error(c.red(`smoke crashed: ${err.stack ?? err}`)); process.exit(2); });