← back to Stars of Design
test/smoke.js
237 lines
#!/usr/bin/env node
'use strict';
// Smoke test for starsofdesign. Hits every public route + verifies key DOM
// markers (canonical, JSON-LD type, BreadcrumbList, footer links). Zero deps.
//
// Usage:
// node test/smoke.js
// SOD_URL=https://… node test/smoke.js
const http = require('http');
const https = require('https');
const { URL } = require('url');
const BASE = process.env.SOD_URL || 'http://127.0.0.1:9928';
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(`starsofdesign smoke — ${BASE}\n`);
// 1. home — perf budget enforced (1500ms cold ceiling; ~5ms warm)
const homeT0 = Date.now();
let r = await fetch('/');
const homeMs = Date.now() - homeT0;
check('home: <1500ms', homeMs < 1500, `actual ${homeMs}ms`);
check('home: 200', r.status === 200);
check('home: title', /Stars of Design/.test(r.body));
check('home: canonical', /rel="canonical"/.test(r.body));
check('home: WebSite schema',/"@type":"WebSite"/.test(r.body));
check('home: SearchAction', /SearchAction/.test(r.body));
check('home: favicon', /rel="icon"/.test(r.body));
check('home: footer Privacy',/\/privacy/.test(r.body));
check('home: footer Terms', /\/terms/.test(r.body));
check('home: X-Content-Type-Options', (r.headers['x-content-type-options'] || '') === 'nosniff');
check('home: X-Frame-Options', /SAMEORIGIN|DENY/.test(r.headers['x-frame-options'] || ''));
check('home: Permissions-Policy', /geolocation=/.test(r.headers['permissions-policy'] || ''));
check('home: canonical', /rel="canonical"/.test(r.body));
check('home: color-scheme', /name="color-scheme"/.test(r.body));
check('home: Referrer-Policy', /strict-origin/.test(r.headers['referrer-policy'] || ''));
check('home: Strict-Transport-Security', /max-age=/.test(r.headers['strict-transport-security'] || ''));
check('home: CSP', /default-src/.test(r.headers['content-security-policy'] || ''));
// 2. /designers
r = await fetch('/designers');
check('designers: 200', r.status === 200);
check('designers: canonical',/rel="canonical"/.test(r.body));
check('designers: ItemList', /"@type":"ItemList"/.test(r.body));
check('designers: sort', /id="sort-select"|name="sort"/.test(r.body));
check('designers: density', /type="range"/.test(r.body));
// 3. /firms
r = await fetch('/firms');
check('firms: 200', r.status === 200);
check('firms: canonical', /rel="canonical"/.test(r.body));
check('firms: ItemList', /"@type":"ItemList"/.test(r.body));
check('firms: sort', /id="sort-select"|name="sort"/.test(r.body));
check('firms: density', /type="range"/.test(r.body));
// 4. /clients
r = await fetch('/clients');
check('clients: 200', r.status === 200);
check('clients: ItemList', /"@type":"ItemList"/.test(r.body));
check('clients: sort', /id="sort-select"|name="sort"/.test(r.body));
check('clients: density', /type="range"/.test(r.body));
// 5. /feed
r = await fetch('/feed');
check('feed: 200', r.status === 200);
// 6. /videos
r = await fetch('/videos');
check('videos: 200', r.status === 200);
// 7. detail pages
r = await fetch('/designers/dorothy-draper');
check('designer/:slug: 200', r.status === 200);
check('designer/:slug: Person', /"@type":"Person"/.test(r.body));
check('designer/:slug: Breadcrumb', /"@type":"BreadcrumbList"/.test(r.body));
r = await fetch('/firms/nick-olsen-style');
check('firm/:slug: 200', r.status === 200);
check('firm/:slug: Organization', /"@type":"Organization"/.test(r.body));
check('firm/:slug: Breadcrumb', /"@type":"BreadcrumbList"/.test(r.body));
r = await fetch('/clients/nick-olsen-style-firm');
check('client/:slug: 200', r.status === 200);
check('client/:slug: Person', /"@type":"Person"/.test(r.body));
check('client/:slug: Breadcrumb', /"@type":"BreadcrumbList"/.test(r.body));
// 8a. /api — discovery doc
r = await fetch('/api');
check('api: 200', r.status === 200);
check('api: discovery doc', /endpoints/.test(r.body) && /Stars of Design API/.test(r.body));
// 8a1. /healthz — k8s liveness alias (no DB, no-store)
r = await fetch('/healthz');
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'));
// HEAD method support — monitoring tools often probe with HEAD
r = await fetch('/healthz', { method: 'HEAD' });
check('healthz: HEAD 200', r.status === 200);
// 8a2. /api/health — perf budget 1000ms (was 500, bumped after flaky cold-
// cache spike). Genuine regressions still fail this; ordinary jitter doesn't.
{
const t0 = Date.now();
r = await fetch('/api/health');
const ms = Date.now() - t0;
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: counts.designers',/"designers":\d+/.test(r.body));
check('api/health: <1000ms', ms < 1000, `actual ${ms}ms`);
}
// 8a3. /api/version — deploy fingerprint
r = await fetch('/api/version');
check('api/version: 200', r.status === 200);
check('api/version: name', /"name":"starsofdesign"/.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: node', /"node":"v\d+/.test(r.body));
check('api/version: no-store', (r.headers['cache-control'] || '').includes('no-store'));
// 8b. /api/stats
r = await fetch('/api/stats');
check('api/stats: 200', r.status === 200);
check('api/stats: designers field', /"designers":/.test(r.body));
// 8c. /api/{designers,firms,clients}/random + /random/{designer,firm,client}
for (const k of ['designers', 'firms', 'clients']) {
r = await fetch(`/api/${k}/random`);
check(`api/${k}/random: 200`, r.status === 200);
check(`api/${k}/random: slug`, /"slug":"[a-z0-9-]+/.test(r.body));
}
for (const k of ['designer', 'firm', 'client']) {
r = await fetch(`/random/${k}`, { redirect: 'manual' });
check(`/random/${k}: 302`, r.status === 302);
}
// 8d. /api/{designers,firms,clients}/featured?n=3 — multi-row companions
for (const k of ['designers', 'firms', 'clients']) {
r = await fetch(`/api/${k}/featured?n=3`);
check(`api/${k}/featured: 200`, r.status === 200);
check(`api/${k}/featured: array`, new RegExp(`"${k}":\\[`).test(r.body));
check(`api/${k}/featured: SWR cache`, /stale-while-revalidate/.test(r.headers['cache-control'] || ''));
}
// 9. /sitemap.xml + /robots.txt + /favicon.ico
r = await fetch('/sitemap.xml');
check('sitemap: 200', r.status === 200);
check('sitemap: urlset', /<urlset/.test(r.body));
check('sitemap: has /privacy', /<loc>[^<]*\/privacy<\/loc>/.test(r.body));
check('sitemap: has /terms', /<loc>[^<]*\/terms<\/loc>/.test(r.body));
check('sitemap: Last-Modified', /GMT$/.test(r.headers['last-modified'] || ''));
r = await fetch('/sitemap.txt');
check('sitemap.txt: 200', r.status === 200);
check('sitemap.txt: has http URL', /^https?:\/\//.test(r.body));
check('sitemap.txt: Last-Modified', /GMT$/.test(r.headers['last-modified'] || ''));
r = await fetch('/robots.txt');
check('robots: 200', r.status === 200);
r = await fetch('/favicon.ico');
check('favicon: 200', r.status === 200);
check('favicon: image/svg', /image\/svg/.test(r.headers['content-type'] || ''));
// gzip negotiated + Vary: Accept-Encoding (cache safety)
r = await fetch('/firms', { headers: { 'Accept-Encoding': 'gzip' } });
check('gzip /firms', (r.headers['content-encoding'] || '') === 'gzip');
check('vary Accept-Encoding', (r.headers['vary'] || '').toLowerCase().includes('accept-encoding'));
// helmet security headers — sod uses helmet, asim doesn't, so this is sod-only.
r = await fetch('/');
check('HSTS header', /max-age=\d+/.test(r.headers['strict-transport-security'] || ''));
check('CSP header', /default-src/.test(r.headers['content-security-policy'] || ''));
// 10. legal pages
r = await fetch('/about');
check('about: 200', r.status === 200);
check('about: Stars of Design h1', /About Stars of Design/.test(r.body));
r = await fetch('/privacy');
check('privacy: 200', r.status === 200);
check('privacy: h1', /<h1[^>]*>Privacy<\/h1>/.test(r.body));
r = await fetch('/terms');
check('terms: 200', r.status === 200);
check('terms: h1', /<h1[^>]*>Terms of use<\/h1>/.test(r.body));
// 11. /submit
r = await fetch('/submit');
check('submit: 200', r.status === 200);
// 12. branded 404 — non-routed path returns HTML with brand name
r = await fetch('/nonexistent-path-smoke-test');
check('404: status 404', r.status === 404);
check('404: branded HTML', /Stars of Design/.test(r.body));
// Report — flags:
// --quiet : only print failures + tally (CI / cron)
// --json : emit a single JSON object (monitoring / dashboards)
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);
});