spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1#!/usr/bin/env node2/**3 * CancerIndex web smoke suite — read-only.4 *5 * BASE_URL=http://127.0.0.1:8253 node qa/smoke.mjs (default)6 * BASE_URL=https://www.cancerindex.io node qa/smoke.mjs (production, read-only)7 *8 * Checks, for every route in ROUTES: HTTP 200, key text present, HTML weight under MAX_KB, no9 * "Data not yet available" on pages that must have data, and — when Playwright is available — no10 * horizontal overflow at 390 px plus a console-error scan. Playwright is resolved from a sibling11 * project's node_modules (never installed globally); without it the suite degrades to HTTP-only.12 *13 * Exit code 1 when any hard check fails. Prints a table.14 */15import { createRequire } from 'node:module';16import { existsSync } from 'node:fs';17import os from 'node:os';18import path from 'node:path';1920const BASE_URL = (process.env.BASE_URL ?? 'http://127.0.0.1:8253').replace(/\/$/, '');21const MAX_KB = Number(process.env.MAX_KB ?? 600);22const TIMEOUT_MS = Number(process.env.TIMEOUT_MS ?? 30000);23const MOBILE_WIDTH = 390;24const ONLY_HTTP = process.env.HTTP_ONLY === '1';2526/** @type {Array<{path: string, expect: string[], mustHaveData?: boolean, optionalLocal?: boolean, maxKb?: number, resolve?: (ctx: Record<string,string>) => string | null, kind?: 'html'|'xml'|'json'}>} */27const ROUTES = [28 { path: '/', expect: ['CancerIndex'], maxKb: 800 },29 { path: '/cancers', expect: ['Cancers'] },30 { path: '/cancer/malignant-pancreatic-neoplasm', expect: ['Malignant Pancreatic Neoplasm', 'CI-CAN-'] },31 // "observations" only appears in the data branch (Freshness "N observations"); the empty branch is caught by mustHaveData.32 { path: '/cancer/malignant-pancreatic-neoplasm/statistics', expect: ['Population statistics', 'observations'], mustHaveData: true },33 { path: '/cancer/malignant-pancreatic-neoplasm/trials', expect: ['Registered studies', 'NCT'] },34 { path: '/cancer/malignant-pancreatic-neoplasm/evidence', expect: ['Curated clinical evidence', 'civic'] },35 { path: '/cancer/malignant-pancreatic-neoplasm/rankings', expect: ['Rankings'] },36 { path: '/gene/TP53', expect: ['TP53', 'Clinical evidence'] },37 { path: '/variant/braf-v600e', expect: ['V600E', 'Evidence by cancer'] },38 { path: '/drug/osimertinib', expect: ['Osimertinib', 'Approvals'], resolve: (ctx) => ctx.drug ?? '/drug/osimertinib' },39 { path: '/trial/<first NCT>', expect: ['NCT', 'Conditions'], resolve: (ctx) => ctx.trial ?? null },40 { path: '/rankings', expect: ['Rankings', 'metric'] },41 { path: '/rankings/trial_gap_ratio', expect: ['Trial Gap Ratio', 'rank'] },42 { path: '/explore', expect: ['Data explorer', 'CSV'], mustHaveData: true },43 { path: '/explore/coverage', expect: ['Coverage'] },44 { path: '/trials/intelligence', expect: ['intelligence', 'HHI'], mustHaveData: true, maxKb: 900 },45 { path: '/trials/terminated', expect: ['NCT'] , maxKb: 900 },46 { path: '/trials/map', expect: ['Trial map', 'ISO'], maxKb: 1200 },47 { path: '/research-gap', expect: ['Research Gap', 'log'], mustHaveData: true, maxKb: 900 },48 { path: '/graph', expect: ['graph', 'CI-CAN-'], maxKb: 1100 },49 { path: '/graph?focus=gene:KRAS', expect: ['KRAS'], maxKb: 900 },50 { path: '/approvals', expect: ['approvals', 'FDA'], maxKb: 900 },51 { path: '/pipeline', expect: ['pipeline', 'Phase'], maxKb: 900 },52 { path: '/methodology/trial-map', expect: ['ISO'] },53 { path: '/pulse', expect: ['What changed in cancer', 'Phase III'] },54 { path: '/biomarkers', expect: ['Biomarkers', 'HER2'] },55 { path: '/biomarker/braf-v600e', expect: ['BRAF', 'NCIt'], maxKb: 900 },56 { path: '/api/v1/biomarkers?limit=3', expect: ['data'], kind: 'json', optionalLocal: true },57 { path: '/year/2025', expect: ['2025 in cancer', 'Phase III'] },58 { path: '/country/canada', expect: ['Clinical trial activity in Canada', 'Oncology approval records'] },59 { path: '/data-updates', expect: ['Data update log', 'ING-'] },60 { path: '/api/v1/research-gap', expect: ['data'], kind: 'json', optionalLocal: true },61 { path: '/api/v1/trials/intelligence?limit=2', expect: ['data'], kind: 'json', optionalLocal: true },62 { path: '/api/v1/epidemiology/metrics', expect: ['data'], kind: 'json', optionalLocal: true },63 { path: '/api/v1/approvals/recent?days=365&limit=5', expect: ['data'], kind: 'json', optionalLocal: true },64 { path: '/api/v1/graph/gene/TP53?limit=5', expect: ['data'], kind: 'json', optionalLocal: true },65 { path: '/rankings/mortality_count', expect: ['mortality', 'rank'] },66 { path: '/taxonomy', expect: ['Taxonomy'] },67 { path: '/sources', expect: ['Sources', 'license'] },68 { path: '/source/ncit-evs', expect: ['NCI', 'ncit-evs'] },69 { path: '/methodology', expect: ['Methodology'] },70 { path: '/search?q=glio', expect: ['Results for', 'glio'] },71 { path: '/sitemap.xml', expect: ['<sitemapindex', '<loc>'], kind: 'xml' },72 { path: '/api/v1/stats', expect: ['data'], kind: 'json', optionalLocal: true },73];7475const 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` };76const isLocal = /^(https?:\/\/)?(127\.0\.0\.1|localhost|0\.0\.0\.0)(:|\/|$)/.test(BASE_URL);7778async function fetchText(url) {79 const ctl = new AbortController();80 const t = setTimeout(() => ctl.abort(), TIMEOUT_MS);81 const started = performance.now();82 try {83 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' } });84 const buf = Buffer.from(await res.arrayBuffer());85 return { status: res.status, bytes: buf.byteLength, text: buf.toString('utf8'), ms: Math.round(performance.now() - started), headers: res.headers, finalUrl: res.url };86 } finally {87 clearTimeout(t);88 }89}9091/** Discover a real drug slug and NCT id from the running site (no DB access from the QA suite). */92async function discover() {93 const ctx = {};94 try {95 const d = await fetchText(`${BASE_URL}/drug/osimertinib`);96 if (d.status === 200) ctx.drug = '/drug/osimertinib';97 else {98 const list = await fetchText(`${BASE_URL}/drugs`);99 const m = /href="(\/drug\/[a-z0-9-]+)"/.exec(list.text);100 if (m) ctx.drug = m[1];101 }102 } catch {}103 try {104 const list = await fetchText(`${BASE_URL}/trials`);105 const m = /href="(\/trial\/NCT\d{8})"/.exec(list.text);106 if (m) ctx.trial = m[1];107 } catch {}108 return ctx;109}110111async function loadPlaywright() {112 if (ONLY_HTTP) return null;113 const candidates = [114 process.env.PLAYWRIGHT_DIR,115 path.join(os.homedir(), 'Desktop/Projets/apps-web/fetcha/qa'),116 path.join(os.homedir(), 'Desktop/Projets/apps-web/rareindex'),117 path.join(os.homedir(), 'Desktop/Projets/apps-web/fetcha'),118 process.cwd(),119 ].filter(Boolean);120 for (const dir of candidates) {121 const pkg = path.join(dir, 'node_modules', 'playwright', 'package.json');122 if (!existsSync(pkg)) continue;123 try {124 const req = createRequire(path.join(dir, 'package.json'));125 const pw = req('playwright');126 const browser = await pw.chromium.launch({ headless: true });127 return { pw, browser, from: dir };128 } catch (err) {129 console.error(c.dim(`playwright at ${dir} unusable: ${err.message.split('\n')[0]}`));130 }131 }132 return null;133}134135function pad(s, n, right = false) {136 s = String(s);137 if (s.length >= n) return s.slice(0, n);138 return right ? ' '.repeat(n - s.length) + s : s + ' '.repeat(n - s.length);139}140141async function main() {142 console.log(`CancerIndex smoke · ${BASE_URL} · max ${MAX_KB} KB · viewport ${MOBILE_WIDTH}px`);143 const ctx = await discover();144 const pwr = await loadPlaywright();145 if (pwr) console.log(c.dim(`Playwright from ${pwr.from}`));146 else console.log(c.yellow('Playwright unavailable — HTTP-only mode (no overflow / console checks)'));147148 const rows = [];149 let failures = 0;150 let warnings = 0;151 const page = pwr ? await pwr.browser.newPage({ viewport: { width: MOBILE_WIDTH, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }) : null;152153 for (const r of ROUTES) {154 const p = r.resolve ? r.resolve(ctx) : r.path;155 if (!p) {156 rows.push({ path: r.path, status: '—', kb: '—', ms: '—', overflow: '—', result: c.yellow('SKIP'), note: 'could not discover an instance' });157 warnings++;158 continue;159 }160 const url = `${BASE_URL}${p}`;161 const problems = [];162 const notes = [];163 let res;164 try {165 res = await fetchText(url);166 } catch (err) {167 rows.push({ path: p, status: 'ERR', kb: '—', ms: '—', overflow: '—', result: c.red('FAIL'), note: err.message });168 failures++;169 continue;170 }171 const kb = Math.round(res.bytes / 1024);172 const optional = r.optionalLocal && isLocal;173 if (res.status !== 200) {174 if (optional && (res.status === 500 || res.status === 502 || res.status === 503)) notes.push(`API proxy target down locally (${res.status}) — optional`);175 else problems.push(`status ${res.status}`);176 } else {177 const lower = res.text.toLowerCase();178 for (const key of r.expect) if (!lower.includes(key.toLowerCase())) problems.push(`missing text "${key}"`);179 if (r.kind !== 'json' && r.kind !== 'xml') {180 if (kb > (r.maxKb ?? MAX_KB)) problems.push(`weight ${kb} KB > ${r.maxKb ?? MAX_KB} KB`);181 if (r.mustHaveData && lower.includes('data not yet available')) problems.push('"Data not yet available" on a page that must have data');182 if (/application error|internal server error|something went wrong rendering/i.test(res.text)) problems.push('error boundary rendered');183 }184 if (r.kind === 'json') {185 try {186 const j = JSON.parse(res.text);187 if (!j || typeof j !== 'object' || !('data' in j)) problems.push('JSON envelope missing "data"');188 } catch {189 problems.push('invalid JSON');190 }191 }192 const cc = res.headers.get('cache-control') ?? '';193 if (/^\/(cancer|gene|drug|trial|source)\//.test(p) || /^\/rankings/.test(p)) {194 if (!/s-maxage/.test(cc)) notes.push(`no s-maxage (cache-control: ${cc || 'none'})`);195 }196 }197198 let overflow = '—';199 if (page && res.status === 200 && r.kind !== 'json' && r.kind !== 'xml') {200 const consoleErrors = [];201 const onConsole = (m) => {202 if (m.type() === 'error') consoleErrors.push(m.text());203 };204 page.on('console', onConsole);205 try {206 await page.goto(url, { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS });207 await page.waitForTimeout(250);208 const m = await page.evaluate(() => {209 const doc = document.documentElement;210 const bodyOverflow = Math.max(doc.scrollWidth, document.body.scrollWidth) - doc.clientWidth;211 // Elements wider than the viewport that are not inside a horizontally scrollable wrapper.212 const offenders = [];213 const vw = doc.clientWidth;214 for (const el of document.querySelectorAll('body *')) {215 const rect = el.getBoundingClientRect();216 if (rect.right > vw + 1 && rect.width > 0) {217 let scrollable = false;218 for (let a = el.parentElement; a; a = a.parentElement) {219 const ov = getComputedStyle(a).overflowX;220 if (ov === 'auto' || ov === 'scroll' || ov === 'hidden' || ov === 'clip') {221 scrollable = true;222 break;223 }224 }225 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`);226 if (offenders.length >= 3) break;227 }228 }229 return { bodyOverflow, offenders };230 });231 overflow = m.bodyOverflow > 1 ? `${m.bodyOverflow}px` : 'ok';232 if (m.bodyOverflow > 1) problems.push(`horizontal overflow ${m.bodyOverflow}px at ${MOBILE_WIDTH}px${m.offenders.length ? ` (${m.offenders.join('; ')})` : ''}`);233 const realErrors = consoleErrors.filter((t) => !/favicon|net::ERR_|Failed to load resource|hydrat/i.test(t));234 if (realErrors.length) notes.push(`${realErrors.length} console error(s): ${realErrors[0].slice(0, 90)}`);235 } catch (err) {236 notes.push(`browser: ${err.message.split('\n')[0].slice(0, 80)}`);237 } finally {238 page.off('console', onConsole);239 }240 }241242 const ok = problems.length === 0;243 if (!ok) failures++;244 if (notes.length) warnings++;245 rows.push({ path: p, status: res.status, kb, ms: res.ms, overflow, result: ok ? c.green('PASS') : c.red('FAIL'), note: [...problems, ...notes].join(' · ') });246 }247248 if (pwr) await pwr.browser.close();249250 console.log('');251 console.log(`${pad('Route', 52)} ${pad('Status', 6, true)} ${pad('KB', 6, true)} ${pad('ms', 6, true)} ${pad('390px', 7)} Result Notes`);252 console.log('-'.repeat(120));253 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) : ''}`);254 console.log('-'.repeat(120));255 const passed = rows.filter((r) => r.result.includes('PASS')).length;256 console.log(`${passed}/${rows.length} routes passed · ${failures} failure(s) · ${warnings} warning(s) · ${BASE_URL}`);257 process.exit(failures ? 1 : 0);258}259260main().catch((err) => {261 console.error(c.red(`smoke crashed: ${err.stack ?? err}`));262 process.exit(2);263});264