← back to build-pages

test/smoke.js

342 lines

// build-pages smoke — same shape as asim/sod harnesses. Run via:
//   node test/smoke.js              (verbose)
//   node test/smoke.js --quiet      (failures + tally only)
//   node test/smoke.js --json       (single JSON object)
const http = require('http');
const https = require('https');
const { URL } = require('url');

const BASE = process.env.BP_URL || 'http://127.0.0.1:9700';
const TIMEOUT_MS = 8_000;

function fetch(path, opts = {}) {
  const u = new URL(path, BASE);
  const lib = u.protocol === 'https:' ? https : http;
  const method = opts.method || 'GET';
  return new Promise((resolve, reject) => {
    const req = lib.request(u, { method, timeout: TIMEOUT_MS, headers: opts.headers || {} }, (res) => {
      let body = '';
      res.on('data', (c) => (body += c));
      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
    });
    req.on('error', reject);
    req.on('timeout', () => { req.destroy(new Error('timeout')); });
    req.end();
  });
}

const checks = [];
let pass = 0, fail = 0;
function check(name, cond, hint = '') {
  if (cond) { pass++; checks.push({ name, ok: true }); }
  else      { fail++; checks.push({ name, ok: false, hint }); }
}

(async () => {
  if (!process.argv.includes('--json')) console.log(`build-pages smoke — ${BASE}\n`);

  // 1. healthz + HEAD + perf budget
  // /healthz is a no-DB JSON literal; <50ms is generous, actual is ~2ms warm.
  {
    const t0 = Date.now();
    let r = await fetch('/healthz');
    const ms = Date.now() - t0;
    check('healthz: 200',            r.status === 200);
    check('healthz: ok JSON',        /"ok":true/.test(r.body));
    check('healthz: no-store',       (r.headers['cache-control'] || '').includes('no-store'));
    check('healthz: <50ms',          ms < 50, `actual ${ms}ms`);
    check('healthz: X-Response-Time',/ms$/.test(r.headers['x-response-time'] || ''));
    r = await fetch('/healthz', { method: 'HEAD' });
    check('healthz: HEAD 200',       r.status === 200);
  }
  let r;

  // 2. /api/health
  r = await fetch('/api/health');
  check('api/health: 200',         r.status === 200);
  check('api/health: ok',          /"ok":true/.test(r.body));
  check('api/health: uptime_s',    /"uptime_s":\d+/.test(r.body));
  check('api/health: projects',    /"projects":\d+/.test(r.body));
  check('api/health: loadavg',     /"loadavg":/.test(r.body));

  // 3. /api/version
  r = await fetch('/api/version');
  check('api/version: 200',        r.status === 200);
  check('api/version: name',       /"name":"build-pages"/.test(r.body));
  check('api/version: git sha',    /"git":"[a-f0-9]{4,}/.test(r.body));
  check('api/version: git resolved', !/"git":"unknown"/.test(r.body));
  check('api/version: no-store',   (r.headers['cache-control'] || '').includes('no-store'));

  // 4. /api/projects discovery
  r = await fetch('/api/projects');
  check('api/projects: 200',       r.status === 200);
  check('api/projects: butlr in list', /"slug":"butlr"/.test(r.body));

  // 4a. /api discovery doc
  r = await fetch('/api');
  check('api: 200',                r.status === 200);
  check('api: name',               /"name":"build-pages API"/.test(r.body));
  check('api: endpoints map',      /"endpoints":/.test(r.body));

  // 4b. /api/projects/:slug detail
  r = await fetch('/api/projects/butlr');
  check('api/projects/butlr: 200', r.status === 200);
  check('api/projects/butlr: commit_count', /"commit_count":\d+/.test(r.body));
  check('api/projects/butlr: head sha', /"head":"[a-f0-9]{4,}/.test(r.body));
  r = await fetch('/api/projects/nope-not-real');
  check('api/projects/?: 404',     r.status === 404);

  // 5. index page
  r = await fetch('/');
  check('home: 200',               r.status === 200);
  check('home: title',             /build-pages/.test(r.body));
  check('home: X-Content-Type-Options', (r.headers['x-content-type-options'] || '') === 'nosniff');
  check('home: Permissions-Policy', /geolocation=/.test(r.headers['permissions-policy'] || ''));
  check('home: fleet totals',      /\d+ projects · \d+ commits · \d+ design-rationale ideas/.test(r.body));
  check('home: recent rail',       /Recent across the fleet/.test(r.body));
  check('home: theme-color',       /name="theme-color"/.test(r.body));
  check('home: top skills rail',   /Top skills \+ agents/.test(r.body));
  check('home: velocity line',     /last 24h: <strong>\d+<\/strong>/.test(r.body));
  check('home: delta arrow',       /class="bp-delta bp-delta-(up|dn|eq)"/.test(r.body));
  check('home: per-project today', /class="bp-today"/.test(r.body));
  check('home: latest-date deep-link', /latest <a href="\/p\/[^"]+\/c\/[0-9a-f]+"/.test(r.body));
  check('home: load badge',        /class="bp-load (bp-load-ok|bp-load-warn|bp-load-hot)"/.test(r.body));
  check('home: 7-day sparkline',   /class="bp-spark"/.test(r.body));
  check('home: deepest rationale rail', /Deepest rationale/.test(r.body));
  check('home: today PT counter', /today \(PT\): <strong>\d+<\/strong>/.test(r.body));
  check('home: yesterday counter', /yesterday: <strong>\d+<\/strong>/.test(r.body));
  check('home: canonical',         /rel="canonical"/.test(r.body));
  check('home: og:title',          /property="og:title"/.test(r.body));
  check('home: WebSite JSON-LD',   /"@type":"WebSite"/.test(r.body));
  check('home: ItemList JSON-LD',  /"@type":"ItemList"/.test(r.body));
  check('home: fleet search input',/id="fleet-q"/.test(r.body));
  check('home: footer /api link',  /href="\/api"/.test(r.body));
  check('home: footer sitemap link', /href="\/sitemap.xml"/.test(r.body));
  check('home: footer fleet.csv',  /href="\/fleet.csv"/.test(r.body));

  // 5b. fleet search ?q= deep-link
  r = await fetch('/?q=whisper');
  check('home ?q=: hydrated',      /value="whisper"/.test(r.body));

  // 6. butlr project page renders + has commit count
  r = await fetch('/p/butlr');
  check('butlr: 200',              r.status === 200);
  check('butlr: title',            /Butlr/.test(r.body));
  check('butlr: commit list',      /commits indexed/.test(r.body));
  check('butlr: search input',     /id="q"/.test(r.body));

  // 6b. ?q= deep-link hydrates the search input on server-render
  r = await fetch('/p/butlr?q=whisper');
  check('butlr ?q=: hydrated',     /value="whisper"/.test(r.body));
  check('butlr: SoftwareSourceCode JSON-LD', /"@type":"SoftwareSourceCode"/.test(r.body));
  check('butlr: export section',   /commits\.csv.*feed\.atom.*project\.json/s.test(r.body));

  // 6d. commit detail page — BreadcrumbList JSON-LD
  // Probe a commit hash that's known to exist via /api/projects/butlr/commits
  {
    const c = await fetch('/api/projects/butlr/commits?limit=1');
    const m = /"hash":"([a-f0-9]+)"/.exec(c.body);
    const h = m ? m[1] : 'HEAD';
    r = await fetch(`/p/butlr/c/${h}`);
    check('commit: 200',             r.status === 200);
    check('commit: BreadcrumbList',  /"@type":"BreadcrumbList"/.test(r.body));
    check('commit: diff +/- highlighting', /bp-d-add|bp-d-rm/.test(r.body));
    check('commit: prev/next nav',   /back to /.test(r.body));
  }

  // 6c. perf budget — warm render of /p/butlr (cache warmed by the calls above)
  // should land well under 250ms. Cold is ~80ms, warm ~17ms. The 250ms ceiling
  // is generous and only fails on genuine regression (cache broken, git slow).
  {
    const t0 = Date.now();
    r = await fetch('/p/butlr');
    const ms = Date.now() - t0;
    check('butlr: warm <250ms',    ms < 250, `actual ${ms}ms`);
    check('butlr: X-Response-Time',/ms$/.test(r.headers['x-response-time'] || ''));
    check('butlr: rendered-in footer', /rendered in \d+ms/.test(r.body));
  }

  // 7. unknown project → 404
  r = await fetch('/p/nope-not-a-project');
  check('unknown project: 404',    r.status === 404);

  // 7b. /about + /privacy + /terms
  r = await fetch('/about');
  check('about: 200',              r.status === 200);
  check('about: build-pages title',/About — build-pages/.test(r.body));
  check('about: explainer copy',   /live build journal/.test(r.body));
  r = await fetch('/privacy');
  check('privacy: 200',            r.status === 200);
  check('privacy: no tracking',    /collects nothing about you/.test(r.body));
  r = await fetch('/terms');
  check('terms: 200',              r.status === 200);
  check('terms: as-is',            /as-is for transparency/.test(r.body));
  check('terms: SWR cache',        /stale-while-revalidate/.test(r.headers['cache-control'] || ''));

  // 8. robots.txt — public crawlable + sitemap referenced
  r = await fetch('/robots.txt');
  check('robots: 200',             r.status === 200);
  check('robots: allow all',       /User-agent: \*/.test(r.body));
  check('robots: sitemap line',    /Sitemap: .*\/sitemap.xml/.test(r.body));

  // 9. sitemap.xml — home + every project page
  r = await fetch('/sitemap.xml');
  check('sitemap: 200',            r.status === 200);
  check('sitemap: urlset',         /<urlset/.test(r.body));
  check('sitemap: has /p/butlr',   /<loc>[^<]*\/p\/butlr<\/loc>/.test(r.body));
  check('sitemap: Last-Modified',  /GMT$/.test(r.headers['last-modified'] || ''));

  // 10. gzip negotiated + Vary: Accept-Encoding (cache safety)
  r = await fetch('/p/butlr', { headers: { 'Accept-Encoding': 'gzip' } });
  check('gzip /p/butlr',           (r.headers['content-encoding'] || '') === 'gzip');
  check('vary Accept-Encoding',    (r.headers['vary'] || '').toLowerCase().includes('accept-encoding'));

  // 11. Atom feed per project + autodiscovery link in <head>
  r = await fetch('/p/butlr/feed.atom');
  check('feed: 200',               r.status === 200);
  check('feed: atom xmlns',        /xmlns="http:\/\/www.w3.org\/2005\/Atom"/.test(r.body));
  check('feed: at least 1 entry',  /<entry>/.test(r.body));
  r = await fetch('/p/butlr');
  check('butlr: feed autodiscover',/rel="alternate" type="application\/atom\+xml"/.test(r.body));
  check('butlr: prev/next nav',    /Other build journals/.test(r.body));
  check('butlr: commit anchors',   /<li id="[a-f0-9]{4,}"/.test(r.body));

  // 11b. CSV export (per project)
  r = await fetch('/p/butlr/commits.csv');
  check('csv: 200',                r.status === 200);
  check('csv: text/csv',           /text\/csv/.test(r.headers['content-type'] || ''));
  check('csv: header row',         /^hash,date,author,subject,body_len/.test(r.body));
  check('csv: attachment',         /attachment;.*butlr-commits/.test(r.headers['content-disposition'] || ''));

  // 11c. CSV export (fleet-wide)
  r = await fetch('/fleet.csv');
  check('fleet csv: 200',          r.status === 200);
  check('fleet csv: project col',  /^project,hash,date,author,subject,body_len/.test(r.body));
  check('fleet csv: butlr in mix', /"butlr",/.test(r.body));

  // 12. /api/projects/:slug/commits — paginated raw JSON
  r = await fetch('/api/projects/butlr/commits?limit=3');
  check('commits api: 200',        r.status === 200);
  check('commits api: total',      /"total":\d+/.test(r.body));
  check('commits api: 3 returned', /"limit":3/.test(r.body));
  check('commits api: hash field', /"hash":"[a-f0-9]{4,}/.test(r.body));

  // 12b. /search alias → /?q=
  r = await fetch('/search?q=whisper', { method: 'GET' });
  check('search: 302',             r.status === 302);
  check('search: redirects to /?q', /\/\?q=whisper/.test(r.headers['location'] || ''));

  // 12b2. favicons reachable as real URLs (OpenSearch <Image> needs this)
  r = await fetch('/favicon.svg');
  check('favicon.svg: 200',        r.status === 200);
  check('favicon.svg: svg type',   /image\/svg/.test(r.headers['content-type'] || ''));
  r = await fetch('/favicon.ico');
  check('favicon.ico: 200',        r.status === 200);

  // 12c. OpenSearch — autodiscovery + descriptor
  r = await fetch('/opensearch.xml');
  check('opensearch: 200',         r.status === 200);
  check('opensearch: descriptor',  /<OpenSearchDescription/.test(r.body));
  check('opensearch: html url',    /template=".+\/\?q=\{searchTerms\}/.test(r.body));
  r = await fetch('/');
  check('home: opensearch autodisc', /rel="search" type="application\/opensearchdescription\+xml"/.test(r.body));

  // 13. branded 404 on unknown route
  r = await fetch('/xyz-no-such-route');
  check('404: status 404',         r.status === 404);
  check('404: branded HTML',       /build-pages/.test(r.body));
  check('404: back-link',          /back to all projects/.test(r.body));

  // 13b. /api/recent — fleet-wide recent commits, with ?since= filter
  r = await fetch('/api/recent?limit=5');
  check('recent: 200',             r.status === 200);
  check('recent: results array',   /"results":\[/.test(r.body));
  check('recent: hash field',      /"hash":"[a-f0-9]{4,}/.test(r.body));
  r = await fetch('/api/recent?since=24h&limit=200');
  check('recent ?since=24h: 200',  r.status === 200);
  check('recent ?since=24h: filter', /"since":"[\d-]+T/.test(r.body));

  // 13b2. fleet-wide facet endpoints (agents + skills + authors)
  for (const f of ['agents', 'skills', 'authors']) {
    r = await fetch(`/api/fleet/${f}`);
    check(`fleet/${f}: 200`,         r.status === 200);
    check(`fleet/${f}: field`,       new RegExp(`"${f}":\\[`).test(r.body));
  }

  // 13b4. fleet/all — one-shot aggregate
  r = await fetch('/api/fleet/all');
  check('fleet/all: 200',          r.status === 200);
  check('fleet/all: projects',     /"projects":\[/.test(r.body));
  check('fleet/all: velocity',     /"velocity":/.test(r.body));
  check('fleet/all: top_skills',   /"top_skills":\[/.test(r.body));

  // 13b2a. fleet/today — calendar-day commits
  r = await fetch('/api/fleet/today');
  check('fleet/today: 200',        r.status === 200);
  check('fleet/today: date field', /"date":"\d{4}-\d{2}-\d{2}"/.test(r.body));

  // 13b2b. fleet/longest — design-rationale essays
  r = await fetch('/api/fleet/longest?limit=5');
  check('fleet/longest: 200',      r.status === 200);
  check('fleet/longest: body_len', /"body_len":\d+/.test(r.body));

  // 13b2c. fleet/buckets — daily counts for charts
  r = await fetch('/api/fleet/buckets?days=7');
  check('fleet/buckets: 200',      r.status === 200);
  check('fleet/buckets: labels',   /"labels":\[/.test(r.body));
  check('fleet/buckets: fleet',    /"fleet":/.test(r.body));

  // 13b3. fleet/velocity — rolling-window commit counts
  r = await fetch('/api/fleet/velocity');
  check('fleet/velocity: 200',     r.status === 200);
  check('fleet/velocity: last_24h',/"last_24h":\d+/.test(r.body));
  check('fleet/velocity: last_7d', /"last_7d":\d+/.test(r.body));
  check('fleet/velocity: by_project', /"by_project":\[/.test(r.body));

  // 13c. per-project facet endpoints (agents / skills / ideas)
  for (const facet of ['agents', 'skills', 'ideas']) {
    r = await fetch(`/api/projects/butlr/${facet}`);
    check(`facet ${facet}: 200`,    r.status === 200);
    check(`facet ${facet}: field`,  new RegExp(`"${facet}":`).test(r.body));
  }

  // 13d. snapshot-path 404 guard — *.bak / *.bak.N / *.pre-* / *.orig / *.rej / ~
  // Editor backups must never serve from any public mount. The guard runs before
  // express.static and returns 404 with empty body so probes don't learn the path.
  for (const p of ['/css/site.css.bak', '/css/site.css.bak.1', '/css/site.css.pre-refactor', '/css/site.css.orig', '/css/site.css.rej', '/css/site.css~']) {
    r = await fetch(p);
    check(`snapshot-guard ${p}: 404`, r.status === 404);
  }

  // 14. cross-project search (round-robin merge + per-project breakdown + ?since=)
  r = await fetch('/api/search?q=whisper&limit=10');
  check('search: 200',             r.status === 200);
  check('search: results array',   /"results":\[/.test(r.body));
  check('search: total field',     /"total":\d+/.test(r.body));
  check('search: by_project',      /"by_project":\[/.test(r.body));
  r = await fetch('/api/search?q=a');
  check('search: <2 chars → empty',/"results":\[\]/.test(r.body));
  // ?since= smoke for both endpoints that gained it via parseSince()
  r = await fetch('/api/search?q=commit&since=7d&limit=5');
  check('search ?since=7d: 200',   r.status === 200);
  r = await fetch('/api/projects/butlr/commits?since=7d&limit=5');
  check('commits ?since=7d: 200',  r.status === 200);
  check('commits ?since=7d: since field', /"since":"[\d-]+T/.test(r.body));

  // Report
  if (process.argv.includes('--json')) {
    console.log(JSON.stringify({ base: BASE, pass, fail, total: pass + fail, checks, as_of: new Date().toISOString() }));
    process.exit(fail === 0 ? 0 : 1);
  }
  const quiet = process.argv.includes('--quiet');
  const lines = quiet
    ? checks.filter(c => !c.ok).map(c => '✗ ' + c.name + (c.hint ? '  (' + c.hint + ')' : ''))
    : checks.map(c => (c.ok ? '✓' : '✗') + ' ' + c.name + (c.hint ? '  (' + c.hint + ')' : ''));
  if (lines.length) console.log(lines.join('\n'));
  console.log(`${quiet && fail === 0 ? '' : '\n'}${pass} passed, ${fail} failed`);
  process.exit(fail === 0 ? 0 : 1);
})().catch((e) => {
  console.error('smoke harness error:', e.message);
  process.exit(2);
});