← 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);
});