#!/usr/bin/env node import { pathToFileURL } from 'node:url'; import { flag, option, parseArgs } from './ops-utils.mjs'; function usage() { return `Usage: node scripts/smoke-test-release.mjs --base-url [options] Read-only release smoke checks: - backend runtime version - frontend root page - frontend products route - products API - V2.2 requirement read path - AI config public endpoint Options: --base-url Deployment base URL (default: http://127.0.0.1) --expected-version Expected /api/v1/health/version payload version --timeout-ms Per-request timeout (default: 5000) --dry-run Print planned checks without making requests --help Show this help `; } function normalizeBaseUrl(value) { const url = new URL(value || 'http://127.0.0.1'); url.pathname = url.pathname.replace(/\/+$/, ''); url.search = ''; url.hash = ''; return url.toString().replace(/\/$/, ''); } export function buildSmokeChecks(baseUrl, expectedVersion = '') { const checks = [ { name: 'backend runtime version', path: '/api/v1/health/version', kind: 'json', validate(payload) { if (payload?.service !== 'server' || typeof payload.version !== 'string') { throw new Error(`unexpected version payload: ${JSON.stringify(payload)}`); } if (expectedVersion && payload.version !== expectedVersion) { throw new Error(`expected version ${expectedVersion}, got ${payload.version}`); } }, }, { name: 'frontend root', path: '/', kind: 'text' }, { name: 'frontend products route', path: '/products', kind: 'text' }, { name: 'products API', path: '/api/v1/products', kind: 'json', validate(payload) { if (!Array.isArray(payload)) { throw new Error('products API did not return an array'); } }, }, { name: 'V2.2 requirement scoped read', path: '/api/v1/v2.2/requirements?productId=__smoke__&limit=1', kind: 'json', validate(payload) { if (!payload || !Array.isArray(payload.items)) { throw new Error('V2.2 requirements response missing items array'); } }, }, { name: 'AI config public endpoint', path: '/api/v1/config/ai', kind: 'json', validate(payload) { if (!payload || !Array.isArray(payload.providers)) { throw new Error('AI config response missing providers array'); } }, }, ]; return checks.map((check) => ({ ...check, url: new URL(check.path, `${baseUrl}/`).toString(), })); } async function fetchWithTimeout(url, timeoutMs) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { signal: controller.signal }); } finally { clearTimeout(timer); } } async function runCheck(check, timeoutMs) { const response = await fetchWithTimeout(check.url, timeoutMs); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } if (check.kind === 'json') { const payload = await response.json(); check.validate?.(payload); } else { await response.text(); } } export async function main(argv = process.argv.slice(2)) { const args = parseArgs(argv); if (flag(args, 'help')) { process.stdout.write(usage()); return; } const baseUrl = normalizeBaseUrl(option(args, 'base-url', 'http://127.0.0.1')); const expectedVersion = option(args, 'expected-version', ''); const timeoutMs = Number.parseInt(option(args, 'timeout-ms', '5000'), 10); if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { throw new Error('--timeout-ms must be a positive number'); } const checks = buildSmokeChecks(baseUrl, expectedVersion); if (flag(args, 'dry-run')) { process.stdout.write(`[dry-run] Release smoke target: ${baseUrl}\n`); for (const check of checks) { process.stdout.write(`GET ${check.url} # ${check.name}\n`); } return; } process.stdout.write(`Release smoke target: ${baseUrl}\n`); const failures = []; for (const check of checks) { try { await runCheck(check, timeoutMs); process.stdout.write(`[pass] ${check.name} ${check.url}\n`); } catch (error) { failures.push(`[fail] ${check.name} ${check.url}: ${error.message}`); process.stderr.write(`${failures.at(-1)}\n`); } } if (failures.length > 0) { throw new Error(`Release smoke failed: ${failures.length} check(s) failed`); } process.stdout.write('Release smoke test passed.\n'); } if (import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((error) => { process.stderr.write(`${error.message}\n`); process.exit(1); }); }