SPB Git

spb/cancerindex

Public
11.4 KB · 240 lines javascript
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, resolve?: (ctx: Record<string,string>) => string | null, kind?: 'html'|'xml'|'json'}>} */27const ROUTES = [28  { path: '/', expect: ['CancerIndex'] },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/mortality_count', expect: ['mortality', 'rank'] },42  { path: '/taxonomy', expect: ['Taxonomy'] },43  { path: '/sources', expect: ['Sources', 'license'] },44  { path: '/source/ncit-evs', expect: ['NCI', 'ncit-evs'] },45  { path: '/methodology', expect: ['Methodology'] },46  { path: '/search?q=glio', expect: ['Results for', 'glio'] },47  { path: '/sitemap.xml', expect: ['<sitemapindex', '<loc>'], kind: 'xml' },48  { path: '/api/v1/stats', expect: ['data'], kind: 'json', optionalLocal: true },49];5051const 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` };52const isLocal = /^(https?:\/\/)?(127\.0\.0\.1|localhost|0\.0\.0\.0)(:|\/|$)/.test(BASE_URL);5354async function fetchText(url) {55  const ctl = new AbortController();56  const t = setTimeout(() => ctl.abort(), TIMEOUT_MS);57  const started = performance.now();58  try {59    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' } });60    const buf = Buffer.from(await res.arrayBuffer());61    return { status: res.status, bytes: buf.byteLength, text: buf.toString('utf8'), ms: Math.round(performance.now() - started), headers: res.headers, finalUrl: res.url };62  } finally {63    clearTimeout(t);64  }65}6667/** Discover a real drug slug and NCT id from the running site (no DB access from the QA suite). */68async function discover() {69  const ctx = {};70  try {71    const d = await fetchText(`${BASE_URL}/drug/osimertinib`);72    if (d.status === 200) ctx.drug = '/drug/osimertinib';73    else {74      const list = await fetchText(`${BASE_URL}/drugs`);75      const m = /href="(\/drug\/[a-z0-9-]+)"/.exec(list.text);76      if (m) ctx.drug = m[1];77    }78  } catch {}79  try {80    const list = await fetchText(`${BASE_URL}/trials`);81    const m = /href="(\/trial\/NCT\d{8})"/.exec(list.text);82    if (m) ctx.trial = m[1];83  } catch {}84  return ctx;85}8687async function loadPlaywright() {88  if (ONLY_HTTP) return null;89  const candidates = [90    process.env.PLAYWRIGHT_DIR,91    path.join(os.homedir(), 'Desktop/Projets/apps-web/fetcha/qa'),92    path.join(os.homedir(), 'Desktop/Projets/apps-web/rareindex'),93    path.join(os.homedir(), 'Desktop/Projets/apps-web/fetcha'),94    process.cwd(),95  ].filter(Boolean);96  for (const dir of candidates) {97    const pkg = path.join(dir, 'node_modules', 'playwright', 'package.json');98    if (!existsSync(pkg)) continue;99    try {100      const req = createRequire(path.join(dir, 'package.json'));101      const pw = req('playwright');102      const browser = await pw.chromium.launch({ headless: true });103      return { pw, browser, from: dir };104    } catch (err) {105      console.error(c.dim(`playwright at ${dir} unusable: ${err.message.split('\n')[0]}`));106    }107  }108  return null;109}110111function pad(s, n, right = false) {112  s = String(s);113  if (s.length >= n) return s.slice(0, n);114  return right ? ' '.repeat(n - s.length) + s : s + ' '.repeat(n - s.length);115}116117async function main() {118  console.log(`CancerIndex smoke · ${BASE_URL} · max ${MAX_KB} KB · viewport ${MOBILE_WIDTH}px`);119  const ctx = await discover();120  const pwr = await loadPlaywright();121  if (pwr) console.log(c.dim(`Playwright from ${pwr.from}`));122  else console.log(c.yellow('Playwright unavailable — HTTP-only mode (no overflow / console checks)'));123124  const rows = [];125  let failures = 0;126  let warnings = 0;127  const page = pwr ? await pwr.browser.newPage({ viewport: { width: MOBILE_WIDTH, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }) : null;128129  for (const r of ROUTES) {130    const p = r.resolve ? r.resolve(ctx) : r.path;131    if (!p) {132      rows.push({ path: r.path, status: '—', kb: '—', ms: '—', overflow: '—', result: c.yellow('SKIP'), note: 'could not discover an instance' });133      warnings++;134      continue;135    }136    const url = `${BASE_URL}${p}`;137    const problems = [];138    const notes = [];139    let res;140    try {141      res = await fetchText(url);142    } catch (err) {143      rows.push({ path: p, status: 'ERR', kb: '—', ms: '—', overflow: '—', result: c.red('FAIL'), note: err.message });144      failures++;145      continue;146    }147    const kb = Math.round(res.bytes / 1024);148    const optional = r.optionalLocal && isLocal;149    if (res.status !== 200) {150      if (optional && (res.status === 500 || res.status === 502 || res.status === 503)) notes.push(`API proxy target down locally (${res.status}) — optional`);151      else problems.push(`status ${res.status}`);152    } else {153      const lower = res.text.toLowerCase();154      for (const key of r.expect) if (!lower.includes(key.toLowerCase())) problems.push(`missing text "${key}"`);155      if (r.kind !== 'json' && r.kind !== 'xml') {156        if (kb > MAX_KB) problems.push(`weight ${kb} KB > ${MAX_KB} KB`);157        if (r.mustHaveData && lower.includes('data not yet available')) problems.push('"Data not yet available" on a page that must have data');158        if (/application error|internal server error|something went wrong rendering/i.test(res.text)) problems.push('error boundary rendered');159      }160      if (r.kind === 'json') {161        try {162          const j = JSON.parse(res.text);163          if (!j || typeof j !== 'object' || !('data' in j)) problems.push('JSON envelope missing "data"');164        } catch {165          problems.push('invalid JSON');166        }167      }168      const cc = res.headers.get('cache-control') ?? '';169      if (/^\/(cancer|gene|drug|trial|source)\//.test(p) || /^\/rankings/.test(p)) {170        if (!/s-maxage/.test(cc)) notes.push(`no s-maxage (cache-control: ${cc || 'none'})`);171      }172    }173174    let overflow = '—';175    if (page && res.status === 200 && r.kind !== 'json' && r.kind !== 'xml') {176      const consoleErrors = [];177      const onConsole = (m) => {178        if (m.type() === 'error') consoleErrors.push(m.text());179      };180      page.on('console', onConsole);181      try {182        await page.goto(url, { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS });183        await page.waitForTimeout(250);184        const m = await page.evaluate(() => {185          const doc = document.documentElement;186          const bodyOverflow = Math.max(doc.scrollWidth, document.body.scrollWidth) - doc.clientWidth;187          // Elements wider than the viewport that are not inside a horizontally scrollable wrapper.188          const offenders = [];189          const vw = doc.clientWidth;190          for (const el of document.querySelectorAll('body *')) {191            const rect = el.getBoundingClientRect();192            if (rect.right > vw + 1 && rect.width > 0) {193              let scrollable = false;194              for (let a = el.parentElement; a; a = a.parentElement) {195                const ov = getComputedStyle(a).overflowX;196                if (ov === 'auto' || ov === 'scroll' || ov === 'hidden' || ov === 'clip') {197                  scrollable = true;198                  break;199                }200              }201              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`);202              if (offenders.length >= 3) break;203            }204          }205          return { bodyOverflow, offenders };206        });207        overflow = m.bodyOverflow > 1 ? `${m.bodyOverflow}px` : 'ok';208        if (m.bodyOverflow > 1) problems.push(`horizontal overflow ${m.bodyOverflow}px at ${MOBILE_WIDTH}px${m.offenders.length ? ` (${m.offenders.join('; ')})` : ''}`);209        const realErrors = consoleErrors.filter((t) => !/favicon|net::ERR_|Failed to load resource|hydrat/i.test(t));210        if (realErrors.length) notes.push(`${realErrors.length} console error(s): ${realErrors[0].slice(0, 90)}`);211      } catch (err) {212        notes.push(`browser: ${err.message.split('\n')[0].slice(0, 80)}`);213      } finally {214        page.off('console', onConsole);215      }216    }217218    const ok = problems.length === 0;219    if (!ok) failures++;220    if (notes.length) warnings++;221    rows.push({ path: p, status: res.status, kb, ms: res.ms, overflow, result: ok ? c.green('PASS') : c.red('FAIL'), note: [...problems, ...notes].join(' · ') });222  }223224  if (pwr) await pwr.browser.close();225226  console.log('');227  console.log(`${pad('Route', 52)} ${pad('Status', 6, true)} ${pad('KB', 6, true)} ${pad('ms', 6, true)} ${pad('390px', 7)} Result  Notes`);228  console.log('-'.repeat(120));229  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) : ''}`);230  console.log('-'.repeat(120));231  const passed = rows.filter((r) => r.result.includes('PASS')).length;232  console.log(`${passed}/${rows.length} routes passed · ${failures} failure(s) · ${warnings} warning(s) · ${BASE_URL}`);233  process.exit(failures ? 1 : 0);234}235236main().catch((err) => {237  console.error(c.red(`smoke crashed: ${err.stack ?? err}`));238  process.exit(2);239});240
Files · 2663b65737c5409bbe90300247714e1d2633efef
Menu

Theme