#!/usr/bin/env node // SSR smoke check: boots the fixture menu API and the built playground // server, then checks each line of an expectations file. // // node testing/smoke-ssr.mjs --output <.output dir> --fixtures --expect [--env KEY=VALUE]... // // Expectation grammar, one check per line ('#' starts a comment line): // STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX] // A value runs until the next ` contains=`, ` notcontains=`, ` type=` or the // end of the line, so it may contain spaces. Requests are plain GETs that do // not follow redirects and carry `X-Forwarded-For: 203.0.113.7`; afterwards // at least one fixture API request must have carried that address (the SSR // fetches forward the visitor's x-forwarded-for). // // The same approach as vue-journal-layer's testing/smoke-ssr.mjs; plain .mjs // with JSDoc types so it runs from node_modules unchanged. import { spawn } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { createServer } from 'node:net' import { join, resolve } from 'node:path' import { startFixtureApi } from './fixture-api.mjs' const VISITOR_IP = '203.0.113.7' const API_PREFIX = '/_menu/api/v1' const API_ENV = 'NUXT_MENU_API_BASE' /** @param {string[]} argv */ function parseArgs(argv) { /** @type {{ output?: string, fixtures?: string, expect?: string, env: Record }} */ const out = { env: {} } for (let i = 0; i < argv.length; i++) { const arg = argv[i] const next = () => { const v = argv[++i] if (v === undefined) throw new Error(`${arg} needs a value`) return v } if (arg === '--output') out.output = next() else if (arg === '--fixtures') out.fixtures = next() else if (arg === '--expect') out.expect = next() else if (arg === '--env') { const kv = next() const eq = kv.indexOf('=') if (eq < 1) throw new Error(`--env expects KEY=VALUE, got ${kv}`) out.env[kv.slice(0, eq)] = kv.slice(eq + 1) } else throw new Error(`unknown argument ${arg}`) } if (!out.output || !out.fixtures || !out.expect) throw new Error('--output, --fixtures and --expect are required') return /** @type {{ output: string, fixtures: string, expect: string, env: Record }} */ (out) } /** * @param {string} line * @returns {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }} */ export function parseExpectLine(line) { const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim()) if (!m) throw new Error('expected "STATUS PATH [options]"') /** @type {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }} */ const check = { status: Number(m[1]), path: m[2] } const rest = m[3] const re = /\s(contains|notcontains|type)=/g /** @type {Array<{ key: string, start: number, valueStart: number }>} */ const marks = [] let hit while ((hit = re.exec(rest)) !== null) marks.push({ key: hit[1], start: hit.index, valueStart: re.lastIndex }) if (marks.length === 0 && rest.trim() !== '') throw new Error(`unknown option text "${rest.trim()}"`) if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') { throw new Error(`unknown option text "${rest.slice(0, marks[0].start).trim()}"`) } /** @type {Record} */ const options = {} marks.forEach((mark, i) => { const end = i + 1 < marks.length ? marks[i + 1].start : rest.length options[mark.key] = rest.slice(mark.valueStart, end).trim() }) return { ...check, ...options } } /** @returns {Promise} */ function freePort() { return new Promise((done, fail) => { const srv = createServer() srv.once('error', fail) srv.listen(0, '127.0.0.1', () => { const { port } = /** @type {import('node:net').AddressInfo} */ (srv.address()) srv.close(() => done(port)) }) }) } /** * Boots `/server/index.mjs` on 127.0.0.1 and waits until it answers. * @param {{ outputDir: string, env: Record, timeoutMs?: number }} opts * @returns {Promise<{ url: string, close: () => Promise }>} */ async function startNuxtServer({ outputDir, env, timeoutMs = 30000 }) { const entry = join(resolve(outputDir), 'server', 'index.mjs') if (!existsSync(entry)) throw new Error(`${entry} does not exist; run pnpm build:playground first`) const port = await freePort() const url = `http://127.0.0.1:${port}` const child = spawn(process.execPath, [entry], { env: { ...process.env, NODE_ENV: 'production', NITRO_HOST: '127.0.0.1', NITRO_PORT: String(port), ...env }, stdio: ['ignore', 'pipe', 'pipe'], }) let log = '' child.stdout.on('data', (b) => (log += b)) child.stderr.on('data', (b) => (log += b)) let exited = false child.once('exit', () => (exited = true)) const close = () => new Promise((/** @type {(v?: void) => void} */ done) => { if (exited) return done() child.once('exit', () => done()) child.kill('SIGTERM') setTimeout(() => { if (!exited) child.kill('SIGKILL') }, 5000).unref() }) const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { if (exited) throw new Error(`server exited before answering\n${log}`) try { await fetch(url + '/', { redirect: 'manual', signal: AbortSignal.timeout(2000) }) return { url, close } } catch { await new Promise((r) => setTimeout(r, 200)) } } await close() throw new Error(`no answer on ${url} within ${timeoutMs} ms\n${log}`) } async function main() { const args = parseArgs(process.argv.slice(2)) const lines = readFileSync(resolve(args.expect), 'utf8') .split('\n') .map((l) => l.trim()) .filter((l) => l !== '' && !l.startsWith('#')) const api = await startFixtureApi({ dir: resolve(args.fixtures) }) /** @type {{ url: string, close: () => Promise } | null} */ let nuxt = null const failures = /** @type {string[]} */ ([]) try { nuxt = await startNuxtServer({ outputDir: args.output, env: { [API_ENV]: `${api.url}${API_PREFIX}`, ...args.env } }) for (const line of lines) { let check try { check = parseExpectLine(line) } catch (err) { failures.push(`${line}: ${/** @type {Error} */ (err).message}`) continue } let res try { res = await fetch(nuxt.url + check.path, { redirect: 'manual', headers: { 'X-Forwarded-For': VISITOR_IP }, signal: AbortSignal.timeout(20000), }) } catch (err) { failures.push(`${line}: request failed: ${/** @type {Error} */ (err).message}`) continue } const body = await res.text() const type = res.headers.get('content-type') ?? '' if (res.status !== check.status) failures.push(`${line}: status ${res.status}, want ${check.status}`) else if (check.contains !== undefined && !body.includes(check.contains)) failures.push(`${line}: body lacks "${check.contains}"`) else if (check.notcontains !== undefined && body.includes(check.notcontains)) failures.push(`${line}: body contains "${check.notcontains}"`) else if (check.type !== undefined && !type.startsWith(check.type)) failures.push(`${line}: content-type "${type}", want prefix "${check.type}"`) } if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) { failures.push('x-forwarded-for: the SSR fetches did not forward the visitor address') } } finally { if (nuxt) await nuxt.close() await api.close() } if (failures.length > 0) { for (const f of failures) console.error(`smoke-ssr: FAIL ${f}`) process.exit(1) } console.log(`smoke-ssr: ok (${lines.length} checks)`) } main().catch((err) => { console.error(`smoke-ssr: FAIL setup: ${err instanceof Error ? err.message : String(err)}`) process.exit(1) })