Files
vue-menu-layer/testing/smoke-ssr.mjs
Jakub Zych d980f4cf2f feat: MenuNav layer for the SummerCMS menu API
- typed client and useMenu/useMenuApi (SSR, menu:<code>:<locale> key,
  server and public bases, x-forwarded-for forwarded also on cached renders)
- MenuNav and recursive MenuNavItem: WAI disclosure navigation, URL
  allowlist with a text fallback, rel on _blank, active link and trail,
  mobile toggle, Esc / focus-out / outside pointer / route change closing
- menu tokens and base CSS, README
- node tests (link rules, client, server-rendered NavItem) and a playground
  SSR smoke against a fixture API
2026-10-12 18:19:53 +02:00

193 lines
7.7 KiB
JavaScript

#!/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 <dir> --expect <file> [--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<string, string> }} */
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<string, string> }} */ (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<string, string>} */
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<number>} */
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 `<outputDir>/server/index.mjs` on 127.0.0.1 and waits until it answers.
* @param {{ outputDir: string, env: Record<string, string>, timeoutMs?: number }} opts
* @returns {Promise<{ url: string, close: () => Promise<void> }>}
*/
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<void> } | 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)
})