From c32bc3a41c46f1bff5d870976bea420ef1193ffe Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Mon, 18 May 2026 01:25:50 -0500 Subject: [PATCH] test: smoke-test that server.js actually boots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every existing api test builds an express() app inline, mounting router + select middleware. None imports server.js itself, because that would call app.listen() and bind a port. Side effect of that isolation: startup-time validators that fire during `app.use()` — express-rate-limit's IPv6 helper check (ERR_ERL_KEY_GEN_IPV6), helmet's option validation, etc. — never run in the test suite. We caught the rate-limit bug (PR #113) only because someone tried `node server.js` manually post-merge. This test pins server-boot health into CI by: 1. Spawning the actual `node server.js` as a child process with a sentinel DB_PASSWORD and PORT=0 (kernel picks a free port). 2. Streaming stdout + stderr; succeeding on the first "Server listening" log line. 3. SIGTERM-ing the process once we've seen it. 4. Failing if 15s pass without that line, or if the process exits before emitting it (with the captured output in the failure message for triage). Catches everything from regressions in helmet/cors/rate-limit option validation to import-time crashes (missing dep, syntax error, etc.). One added test, 480 pass / 4 skip overall. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/api/server-boots.test.js | 98 ++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/api/server-boots.test.js diff --git a/tests/api/server-boots.test.js b/tests/api/server-boots.test.js new file mode 100644 index 0000000..df4020a --- /dev/null +++ b/tests/api/server-boots.test.js @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// Smoke-test: server.js boots without throwing. +// +// Why this exists: every other test file in this directory builds an +// express() app inline (mounting router + select middleware) rather +// than importing server.js, because importing server.js would call +// `app.listen()` and bind a port. That isolation means startup-time +// validators that fire in `app.use()` calls — express-rate-limit's +// IPv6 helper check, helmet's CSP options validation, etc. — never +// run in the test suite. +// +// We caught one real bug (ERR_ERL_KEY_GEN_IPV6, fixed in PR #113) +// only because someone tried `node server.js` manually. This test +// pins server-boot health into CI: spawn the actual process, wait +// for the "Server listening" log line on stdout/stderr, then SIGTERM +// it. Anything that throws at app-build time before that log line +// (or never reaches it within the timeout) fails the test. + +import { describe, test, expect } from 'vitest'; +import { spawn } from 'node:child_process'; +import { resolve } from 'node:path'; + +const SERVER_PATH = resolve(__dirname, '../../server.js'); +const TIMEOUT_MS = 15_000; + +describe('server.js boots without throwing', () => { + test('emits the "Server listening" log line within the timeout', async () => { + // Use a sentinel password so the DB-config init doesn't warn, + // and a non-standard port to avoid colliding with a real + // server that might be running on :3000 during local dev. + const port = 0; // 0 = let the kernel pick a free port + const env = { + ...process.env, + DB_PASSWORD: 'test-only-not-real', + PORT: String(port), + HOST: '127.0.0.1', + LOG_LEVEL: 'info', + // Disable rate limiter on a fresh process — it's not the + // path we're smoke-testing (it ran during app.use() above + // already, which is the whole point). + RATE_LIMIT_MAX: '100', + }; + const child = spawn(process.execPath, [SERVER_PATH], { + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => { stdout += d.toString(); }); + child.stderr.on('data', (d) => { stderr += d.toString(); }); + + // Wait for "Server listening" or process exit or timeout. + const result = await new Promise((resolveP) => { + const timer = setTimeout(() => { + child.kill('SIGTERM'); + resolveP({ kind: 'timeout' }); + }, TIMEOUT_MS); + + const checkOutput = () => { + if (/Server listening/.test(stdout + stderr)) { + clearTimeout(timer); + child.kill('SIGTERM'); + resolveP({ kind: 'ok' }); + } + }; + child.stdout.on('data', checkOutput); + child.stderr.on('data', checkOutput); + + child.on('exit', (code, signal) => { + clearTimeout(timer); + resolveP({ kind: 'exit', code, signal }); + }); + }); + + // Drain any remaining output buffers. + await new Promise((r) => setTimeout(r, 50)); + + if (result.kind === 'timeout') { + throw new Error( + 'server.js did not emit "Server listening" within ' + + `${TIMEOUT_MS}ms.\nstdout:\n${stdout}\nstderr:\n${stderr}`, + ); + } + if (result.kind === 'exit' && !/Server listening/.test(stdout + stderr)) { + throw new Error( + `server.js exited (code ${result.code}, signal ${result.signal}) ` + + 'before emitting "Server listening".\n' + + `stdout:\n${stdout}\nstderr:\n${stderr}`, + ); + } + // Either we saw the line and killed the process, or the + // process exited cleanly after we already saw it. + expect(stdout + stderr).toMatch(/Server listening/); + }, TIMEOUT_MS + 5_000); +});