#!/usr/bin/env node import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import process from 'node:process'; const DEFAULT_HEALTH_PATHS = ['/health', '/api/health', '/status', '/']; const defaults = { api: process.env.API_URL || process.env.VITE_API_BASE_URL || 'http://localhost:4000', web: process.env.WEB_URL || process.env.VITE_WEB_BASE_URL || 'http://localhost:5173', scenario: process.env.SMOKE_SCENARIO || '', timeoutMs: parseInteger(process.env.SMOKE_TIMEOUT_MS, 5000), retries: parseInteger(process.env.SMOKE_RETRIES, 2), strict: toBoolean(process.env.SMOKE_STRICT, false), json: false, skipApi: toBoolean(process.env.SMOKE_SKIP_API, false), skipWeb: toBoolean(process.env.SMOKE_SKIP_WEB, false), healthPaths: process.env.SMOKE_HEALTH_PATHS ? process.env.SMOKE_HEALTH_PATHS.split(',').map((path) => path.trim()).filter(Boolean) : DEFAULT_HEALTH_PATHS }; const options = parseArgs(process.argv.slice(2), defaults); if (typeof fetch !== 'function' || typeof AbortController !== 'function') { console.error('This smoke script requires Node.js 18+ because it uses the built-in fetch and AbortController APIs.'); process.exit(2); } const startedAt = Date.now(); const result = { startedAt: new Date(startedAt).toISOString(), options: { api: options.api, web: options.web, scenario: options.scenario || null, timeoutMs: options.timeoutMs, retries: options.retries, strict: options.strict, skipApi: options.skipApi, skipWeb: options.skipWeb }, checks: [], passed: false, durationMs: 0 }; try { if (!options.skipWeb) { result.checks.push(await checkWebApp(options.web, options)); } if (!options.skipApi) { result.checks.push(await checkApiHealth(options.api, options)); } if (options.scenario) { result.checks.push(await runScenario(options.scenario, options)); } else { result.checks.push({ name: 'API user-flow scenario', status: 'skipped', detail: 'No --scenario file provided. Health checks ran; pass a scenario JSON file to exercise collect/trivia/prediction flows over HTTP.' }); } const failures = result.checks.filter((check) => check.status === 'failed'); result.passed = failures.length === 0; result.durationMs = Date.now() - startedAt; writeResult(result, options); if (!result.passed) { process.exit(1); } } catch (error) { result.checks.push({ name: 'smoke runner', status: 'failed', detail: error instanceof Error ? error.message : String(error) }); result.passed = false; result.durationMs = Date.now() - startedAt; writeResult(result, options); process.exit(1); } function usage() { return ` Fan Passport HTTP smoke checks Usage: node scripts/qa/http-smoke.mjs [options] Options: --api API base URL. Default: API_URL, VITE_API_BASE_URL, or http://localhost:4000 --web Web app URL. Default: WEB_URL, VITE_WEB_BASE_URL, or http://localhost:5173 --scenario Optional JSON scenario file containing ordered API requests. --timeout Request timeout in milliseconds. Default: 5000 --retries Retries for transient request failures. Default: 2 --health-paths Comma-separated health paths to try. Default: /health,/api/health,/status,/ --strict Fail scenario expectations aggressively and require JSON where requested. --json Print machine-readable JSON output. --skip-api Skip API health check. --skip-web Skip web app check. --help Show this help. Scenario format: { "variables": { "userId": "demo-user" }, "defaults": { "headers": { "content-type": "application/json" } }, "requests": [ { "name": "collect first team", "method": "POST", "path": "/api/collect", "body": { "userId": "{{userId}}", "type": "team", "id": "england" }, "expect": { "status": [200, 201], "json": true, "requiredJsonPaths": ["user", "passport"], "min": { "passport.xp": 1 } }, "save": { "xpAfterCollection": "passport.xp" } } ] } The runner intentionally supports configurable paths so it can smoke-test local builds, preview deployments, and future API route names without changing this script. `.trim(); } function parseArgs(args, initial) { const parsed = { ...initial }; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; switch (arg) { case '--api': parsed.api = requireValue(args, ++index, arg); break; case '--web': parsed.web = requireValue(args, ++index, arg); break; case '--scenario': parsed.scenario = requireValue(args, ++index, arg); break; case '--timeout': parsed.timeoutMs = parseInteger(requireValue(args, ++index, arg), parsed.timeoutMs); break; case '--retries': parsed.retries = parseInteger(requireValue(args, ++index, arg), parsed.retries); break; case '--health-paths': parsed.healthPaths = requireValue(args, ++index, arg) .split(',') .map((path) => path.trim()) .filter(Boolean); break; case '--strict': parsed.strict = true; break; case '--json': parsed.json = true; break; case '--skip-api': parsed.skipApi = true; break; case '--skip-web': parsed.skipWeb = true; break; case '--help': case '-h': console.log(usage()); process.exit(0); break; default: throw new Error(`Unknown option "${arg}". Run with --help for usage.`); } } if (parsed.timeoutMs < 100) { throw new Error('--timeout must be at least 100ms.'); } if (parsed.retries < 0) { throw new Error('--retries must be zero or greater.'); } parsed.api = normalizeUrl(parsed.api, '--api'); parsed.web = normalizeUrl(parsed.web, '--web'); return parsed; } function requireValue(args, index, optionName) { const value = args[index]; if (!value || value.startsWith('--')) { throw new Error(`${optionName} requires a value.`); } return value; } function parseInteger(value, fallback) { if (value === undefined || value === null || value === '') { return fallback; } const parsed = Number.parseInt(String(value), 10); return Number.isFinite(parsed) ? parsed : fallback; } function toBoolean(value, fallback) { if (value === undefined || value === null || value === '') { return fallback; } return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); } function normalizeUrl(value, optionName) { try { return new URL(value).toString().replace(/\/$/, ''); } catch { throw new Error(`${optionName} must be a valid absolute URL. Received: ${value}`); } } async function checkWebApp(baseUrl, optionsForRun) { const check = { name: 'web app shell', status: 'pending', url: baseUrl, detail: '', durationMs: 0 }; const started = Date.now(); try { const response = await requestWithRetries( `GET ${baseUrl}`, baseUrl, { method: 'GET', headers: { accept: 'text/html,application/xhtml+xml' } }, optionsForRun ); check.durationMs = Date.now() - started; if (response.status < 200 || response.status >= 400) { return fail(check, `Expected 2xx/3xx HTML response, received HTTP ${response.status}.`); } const contentType = response.headers.get('content-type') || ''; const body = response.bodyText || ''; if (!contentType.includes('text/html') && !body.toLowerCase().includes('= 200 && response.status < 400) { check.status = 'passed'; check.durationMs = Date.now() - started; check.url = url; check.detail = `Health endpoint responded with HTTP ${response.status} in ${check.durationMs}ms.`; return check; } errors.push(`${path}: HTTP ${response.status}`); } catch (error) { errors.push(`${path}: ${errorMessage(error)}`); } } check.durationMs = Date.now() - started; return fail(check, `No health endpoint returned 2xx/3xx. Attempts: ${errors.join('; ')}`); } async function runScenario(filePath, optionsForRun) { const check = { name: 'API user-flow scenario', status: 'pending', file: filePath, detail: '', steps: [], durationMs: 0 }; const started = Date.now(); if (!existsSync(filePath)) { return fail(check, `Scenario file does not exist: ${filePath}`); } const scenario = JSON.parse(await readFile(filePath, 'utf8')); validateScenario(scenario); const variables = { ...(scenario.variables || {}) }; const baseUrl = scenario.baseUrl ? normalizeUrl(scenario.baseUrl, 'scenario.baseUrl') : optionsForRun.api; const defaultHeaders = normalizeHeaders(scenario.defaults?.headers || {}); for (let index = 0; index < scenario.requests.length; index += 1) { const requestDefinition = scenario.requests[index]; const step = { index: index + 1, name: requestDefinition.name || `request ${index + 1}`, status: 'pending', method: (requestDefinition.method || 'GET').toUpperCase(), url: '', durationMs: 0, detail: '' }; const stepStarted = Date.now(); try { const url = buildRequestUrl(baseUrl, requestDefinition, variables); step.url = url; const headers = { ...defaultHeaders, ...normalizeHeaders(interpolate(requestDefinition.headers || {}, variables)) }; let body; if (requestDefinition.body !== undefined) { const interpolatedBody = interpolate(requestDefinition.body, variables); if (!hasHeader(headers, 'content-type')) { headers['content-type'] = 'application/json'; } body = isJsonContentType(headers) ? JSON.stringify(interpolatedBody) : String(interpolatedBody); } const response = await requestWithRetries( `${step.method} ${url}`, url, { method: step.method, headers, body }, optionsForRun ); const failures = evaluateExpectations(requestDefinition.expect || {}, response, variables, optionsForRun.strict); if (failures.length > 0) { step.status = 'failed'; step.detail = failures.join('; '); check.steps.push(step); check.durationMs = Date.now() - started; return fail(check, `Scenario failed at step ${step.index} "${step.name}": ${step.detail}`); } if (requestDefinition.save) { if (response.json === undefined) { throw new Error(`Step "${step.name}" requested saved variables but the response was not JSON.`); } for (const [variableName, jsonPath] of Object.entries(requestDefinition.save)) { const value = getByPath(response.json, jsonPath); if (value === undefined) { throw new Error(`Cannot save "${variableName}": JSON path "${jsonPath}" was not present.`); } variables[variableName] = value; } } step.status = 'passed'; step.durationMs = Date.now() - stepStarted; step.detail = `HTTP ${response.status}`; check.steps.push(step); } catch (error) { step.status = 'failed'; step.durationMs = Date.now() - stepStarted; step.detail = errorMessage(error); check.steps.push(step); check.durationMs = Date.now() - started; return fail(check, `Scenario failed at step ${step.index} "${step.name}": ${step.detail}`); } } check.status = 'passed'; check.durationMs = Date.now() - started; check.detail = `Executed ${scenario.requests.length} request${scenario.requests.length === 1 ? '' : 's'} in ${check.durationMs}ms.`; return check; } function validateScenario(scenario) { if (!scenario || typeof scenario !== 'object') { throw new Error('Scenario JSON must be an object.'); } if (!Array.isArray(scenario.requests) || scenario.requests.length === 0) { throw new Error('Scenario JSON must contain a non-empty "requests" array.'); } for (const [index, requestDefinition] of scenario.requests.entries()) { if (!requestDefinition || typeof requestDefinition !== 'object') { throw new Error(`Scenario request ${index + 1} must be an object.`); } if (!requestDefinition.path && !requestDefinition.url) { throw new Error(`Scenario request ${index + 1} must define "path" or "url".`); } } } function buildRequestUrl(baseUrl, requestDefinition, variables) { const rawUrl = requestDefinition.url || requestDefinition.path; const interpolated = interpolate(rawUrl, variables); if (typeof interpolated !== 'string') { throw new Error('Request path/url must resolve to a string.'); } return interpolated.startsWith('http://') || interpolated.startsWith('https://') ? interpolated : joinUrl(baseUrl, interpolated); } function normalizeHeaders(headers) { const normalized = {}; for (const [key, value] of Object.entries(headers || {})) { normalized[key.toLowerCase()] = String(value); } return normalized; } function hasHeader(headers, headerName) { return Object.keys(headers).some((key) => key.toLowerCase() === headerName.toLowerCase()); } function isJsonContentType(headers) { const contentType = headers['content-type'] || headers['Content-Type'] || ''; return contentType.toLowerCase().includes('application/json'); } async function requestWithRetries(label, url, requestOptions, optionsForRun) { let lastError; for (let attempt = 0; attempt <= optionsForRun.retries; attempt += 1) { try { return await requestOnce(url, requestOptions, optionsForRun.timeoutMs); } catch (error) { lastError = error; if (attempt === optionsForRun.retries) { break; } await sleep(150 * (attempt + 1)); } } throw new Error(`${label} failed after ${optionsForRun.retries + 1} attempt(s): ${errorMessage(lastError)}`); } async function requestOnce(url, requestOptions, timeoutMs) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { ...requestOptions, signal: controller.signal }); const bodyText = await response.text(); const contentType = response.headers.get('content-type') || ''; let json; if (bodyText && contentType.toLowerCase().includes('application/json')) { try { json = JSON.parse(bodyText); } catch (error) { throw new Error(`Response advertised JSON but could not be parsed: ${errorMessage(error)}`); } } return { status: response.status, ok: response.ok, headers: response.headers, bodyText, json }; } catch (error) { if (error?.name === 'AbortError') { throw new Error(`Timed out after ${timeoutMs}ms`); } throw error; } finally { clearTimeout(timeout); } } function evaluateExpectations(expectations, response, variables, strict) { const failures = []; if (expectations.status !== undefined && !matchesStatusExpectation(response.status, expectations.status)) { failures.push(`expected status ${JSON.stringify(expectations.status)}, received ${response.status}`); } if (expectations.status === undefined && strict && (response.status < 200 || response.status >= 300)) { failures.push(`expected a 2xx response in strict mode, received ${response.status}`); } if (expectations.json === true && response.json === undefined) { failures.push('expected a JSON response'); } if (expectations.json === false && response.json !== undefined) { failures.push('expected a non-JSON response'); } if (expectations.textIncludes) { const expectedSnippets = Array.isArray(expectations.textIncludes) ? expectations.textIncludes : [expectations.textIncludes]; for (const snippet of expectedSnippets) { const expected = String(interpolate(snippet, variables)); if (!response.bodyText.includes(expected)) { failures.push(`expected response text to include "${expected}"`); } } } const requiredPaths = [ ...(expectations.requiredJsonPaths || []), ...(expectations.contains || []) ]; for (const path of requiredPaths) { if (response.json === undefined) { failures.push(`cannot check JSON path "${path}" because response was not JSON`); } else if (getByPath(response.json, path) === undefined) { failures.push(`expected JSON path "${path}" to exist`); } } for (const [path, expectedValue] of Object.entries(expectations.equals || {})) { if (response.json === undefined) { failures.push(`cannot check JSON equality for "${path}" because response was not JSON`); continue; } const actual = getByPath(response.json, path); const expected = interpolate(expectedValue, variables); if (!deepEqual(actual, expected)) { failures.push(`expected JSON path "${path}" to equal ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`); } } for (const [path, expectedValue] of Object.entries(expectations.includes || {})) { if (response.json === undefined) { failures.push(`cannot check JSON inclusion for "${path}" because response was not JSON`); continue; } const actual = getByPath(response.json, path); const expected = interpolate(expectedValue, variables); if (Array.isArray(actual)) { if (!actual.some((item) => deepEqual(item, expected))) { failures.push(`expected JSON array "${path}" to include ${JSON.stringify(expected)}`); } } else if (typeof actual === 'string') { if (!actual.includes(String(expected))) { failures.push(`expected JSON string "${path}" to include "${expected}"`); } } else { failures.push(`JSON path "${path}" is not an array or string and cannot satisfy includes`); } } for (const [path, minimum] of Object.entries(expectations.min || {})) { if (response.json === undefined) { failures.push(`cannot check JSON minimum for "${path}" because response was not JSON`); continue; } const actual = getByPath(response.json, path); const expectedMinimum = Number(interpolate(minimum, variables)); if (typeof actual !== 'number' || actual < expectedMinimum) { failures.push(`expected JSON path "${path}" to be at least ${expectedMinimum}, received ${JSON.stringify(actual)}`); } } return failures; } function matchesStatusExpectation(actualStatus, expectation) { if (Array.isArray(expectation)) { return expectation.some((entry) => matchesStatusExpectation(actualStatus, entry)); } if (typeof expectation === 'number') { return actualStatus === expectation; } if (typeof expectation === 'string') { const rangeMatch = expectation.match(/^([1-5])xx$/i); if (rangeMatch) { const lower = Number(rangeMatch[1]) * 100; return actualStatus >= lower && actualStatus < lower + 100; } const exact = Number.parseInt(expectation, 10); if (Number.isFinite(exact)) { return actualStatus === exact; } } return false; } function interpolate(value, variables) { if (typeof value === 'string') { const exactMatch = value.match(/^{{\s*([\w.-]+)\s*}}$/); if (exactMatch) { const exactValue = getByPath(variables, exactMatch[1]); if (exactValue === undefined) { throw new Error(`Scenario variable "${exactMatch[1]}" is not defined.`); } return exactValue; } return value.replace(/{{\s*([\w.-]+)\s*}}/g, (_, key) => { const replacement = getByPath(variables, key); if (replacement === undefined) { throw new Error(`Scenario variable "${key}" is not defined.`); } return String(replacement); }); } if (Array.isArray(value)) { return value.map((entry) => interpolate(entry, variables)); } if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value).map(([key, entry]) => [key, interpolate(entry, variables)]) ); } return value; } function getByPath(source, path) { if (!path) { return source; } const parts = String(path) .replace(/\[(\d+)]/g, '.$1') .split('.') .filter(Boolean); let current = source; for (const part of parts) { if (current === null || current === undefined) { return undefined; } current = current[part]; } return current; } function deepEqual(left, right) { return JSON.stringify(left) === JSON.stringify(right); } function joinUrl(baseUrl, path) { if (path.startsWith('http://') || path.startsWith('https://')) { return path; } const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; const relativePath = path.replace(/^\/+/, ''); return new URL(relativePath, base).toString(); } function fail(check, detail) { check.status = 'failed'; check.detail = detail; return check; } function sleep(milliseconds) { return new Promise((resolve) => { setTimeout(resolve, milliseconds); }); } function errorMessage(error) { return error instanceof Error ? error.message : String(error); } function writeResult(output, optionsForRun) { if (optionsForRun.json) { console.log(JSON.stringify(output, null, 2)); return; } console.log(`Fan Passport smoke checks ${output.passed ? 'passed' : 'failed'} in ${output.durationMs}ms`); console.log(''); for (const check of output.checks) { const icon = check.status === 'passed' ? '✓' : check.status === 'skipped' ? '○' : '✗'; console.log(`${icon} ${check.name}: ${check.status}`); if (check.detail) { console.log(` ${check.detail}`); } if (check.url) { console.log(` ${check.url}`); } if (check.steps?.length) { for (const step of check.steps) { const stepIcon = step.status === 'passed' ? ' ✓' : ' ✗'; console.log(`${stepIcon} ${step.index}. ${step.name} (${step.method} ${step.url}) — ${step.detail}`); } } } }