diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 3b2c97c..5b9498f 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -2,13 +2,14 @@ # @personal-blog/server — EPPP public server application image. # -# [E00-S02-T01] baseline: builds the workspace server package with the pinned -# toolchain (Node 24.19.0 + pnpm 11.23.0, frozen lockfile) and runs the compiled -# entrypoint. The server is still a bootstrap placeholder (its module loads and -# exits cleanly); the Fastify 5 application shell that turns it into a serving -# process lands in a later story, and the health gate (T02), health endpoint -# (T03), volume persistence (T04), non-root/read-only hardening and multi-arch -# targets are later E00-S02 tasks — all out of scope here. +# [E00-S02-T01/T02/T03] baseline: builds the workspace server package with the +# pinned toolchain (Node 24.19.0 + pnpm 11.23.0, frozen lockfile) and runs the +# compiled entrypoint. Since T03 the entrypoint is a minimal Node `node:http` +# server answering `GET /health` with `{"status":"ok"}` (HTTP 200) on port +# 3000, so the app container stays up and the health endpoint succeeds. The +# Fastify 5 application shell (and the real HTTP API) lands in a later story; +# volume persistence (T04), non-root/read-only hardening (T05) and multi-arch +# targets remain later E00-S02 tasks — all out of scope here. # # Image base: node:24.19.0-bookworm-slim (glibc Debian) per Technology-Stack # §5.4 — argon2 is a native dependency and musl/Alpine causes native-module diff --git a/apps/server/package.json b/apps/server/package.json index 4044b07..62d5b7e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -3,12 +3,15 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "EPPP public server application. Bootstrap placeholder — the Fastify 5 application shell lands in a later story.", + "description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03); the Fastify 5 application shell lands in a later story.", "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "start": "node dist/index.js" }, + "devDependencies": { + "@types/node": "24.13.3" + }, "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index bd4187d..aad34d0 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,8 +1,69 @@ /** * @personal-blog/server — EPPP public server application. * - * Bootstrap placeholder so apps/ is a real workspace package that compiles - * under tsconfig.base.json. The Fastify 5 application shell (and its real - * exports) lands in a later story. + * [E00-S02-T03] minimal serving process: a small HTTP server built on Node's + * `node:http` (no runtime dependencies yet) that answers the application + * health endpoint. `GET /health` reports a healthy application — HTTP 200 with + * `{"status":"ok"}` — so the Compose stack's `app` service stays up and the + * health endpoint succeeds once the stack is running. + * + * The Fastify 5 application shell (and the real HTTP API) lands in a later + * story; this bootstrap keeps the application health-checkable until then. */ -export {}; + +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; + +/** Port the server listens on; `PORT` overrides the container default (3000). */ +const PORT = resolvePort(process.env.PORT); + +/** Health payload — reports a healthy application. */ +const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' }); + +/** Payload for any route that is not the health endpoint. */ +const NOT_FOUND_PAYLOAD = JSON.stringify({ error: 'not found' }); + +/** + * Resolves the listen port from `PORT` (default 3000, matching the Dockerfile + * `EXPOSE 3000` and the compose `:3000` container port). A non-numeric or + * out-of-range override falls back to the default so a bad `PORT` value cannot + * crash the process at startup. + */ +function resolvePort(raw: string | undefined): number { + const port = Number(raw ?? 3000); + return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3000; +} + +/** Writes a JSON response with an explicit content-length. */ +function sendJson(res: ServerResponse, statusCode: number, body: string): void { + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +/** + * Routes one request. The application only serves the health endpoint at this + * stage; anything else is a 404 so misconfiguration is loud. + */ +function handleRequest(req: IncomingMessage, res: ServerResponse): void { + if (req.method === 'GET' && (req.url ?? '/') === '/health') { + sendJson(res, 200, HEALTH_PAYLOAD); + return; + } + sendJson(res, 404, NOT_FOUND_PAYLOAD); +} + +const server = createServer(handleRequest); + +server.listen(PORT, () => { + console.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`); +}); + +// `docker stop` (Compose down) and Ctrl-C send SIGTERM/SIGINT — close the +// server and exit cleanly instead of being killed mid-request. +for (const signal of ['SIGTERM', 'SIGINT'] as const) { + process.on(signal, () => { + server.close(() => process.exit(0)); + }); +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 5285d28..bb07ae1 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "types": ["node"] }, "include": ["src"] } diff --git a/compose.yaml b/compose.yaml index bad9a35..3def6eb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,4 +1,4 @@ -# EPPP Docker Compose baseline — [E00-S02-T01/T02] +# EPPP Docker Compose baseline — [E00-S02-T01/T02/T03] # # `docker compose up -d` starts both the database (PostgreSQL) and the # application (@personal-blog/server). Rollback: `docker compose down`. @@ -8,8 +8,11 @@ # `condition: service_healthy`, so the application does not start until the # database is accepting connections. # -# Explicitly out of scope for T01/T02 (land in later E00-S02 tasks): -# - application health endpoint (T03) +# Application health endpoint (T03): the app serves `GET /health` (HTTP 200 + +# `{"status":"ok"}`) on port 3000, so the app container stays up and the +# health endpoint succeeds once the stack is running. +# +# Explicitly out of scope for T01/T02/T03 (land in later E00-S02 tasks): # - DB volume persistence (T04) # # All values have defaults so `docker compose up -d` works from a clean clone diff --git a/docs/development/non-container.md b/docs/development/non-container.md index ef9ca38..57254b7 100644 --- a/docs/development/non-container.md +++ b/docs/development/non-container.md @@ -15,7 +15,7 @@ The workspace is a pnpm monorepo with three package groups: | Group | Path | Purpose | | --- | --- | --- | -| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Bootstrap placeholder — the Fastify 5 application shell lands in a later story. | +| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03); the Fastify 5 application shell lands in a later story. | | `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. | | `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. | @@ -86,15 +86,16 @@ pnpm --filter @personal-blog/server start ``` This runs the `start` script of `apps/server` (`node dist/index.js`), i.e. the -compiled application entrypoint. Two things to know at bootstrap: +compiled application entrypoint. Two things to know: 1. The command **must follow `pnpm build`** — the `start` script executes the compiled artifact in `dist/`, it does not compile first. -2. `apps/server` is currently a **bootstrap placeholder**: its entrypoint is an - empty module, so starting it loads the module and exits cleanly (exit 0) - without opening an HTTP port yet. The real Fastify 5 application shell — - which turns this into a serving process — lands in a later story; the - `start` command shape above stays the same once it does. +2. Since [E00-S02-T03], `apps/server` serves the **application health + endpoint**: starting it opens an HTTP server on port 3000 answering + `GET /health` with HTTP 200 and `{"status":"ok"}`, so the process stays up. + The real Fastify 5 application shell — which turns this into the full + serving API — lands in a later story; the `start` command shape stays the + same once it does. To run the compiled output of any other workspace package directly: @@ -121,7 +122,7 @@ pnpm install --frozen-lockfile # exit 0, lockfile untouched pnpm build # 3/3 packages emit dist/, exit 0 pnpm typecheck # 3/3 packages pass --noEmit, exit 0 pnpm test # 10/10 pass, exit 0 -pnpm --filter @personal-blog/server start # loads compiled server entrypoint, exit 0 +pnpm --filter @personal-blog/server start # serves GET /health on port 3000, stays up ``` ## Troubleshooting @@ -131,7 +132,7 @@ pnpm --filter @personal-blog/server start # loads compiled server entrypoint, | `pnpm: command not found` | Corepack shims not activated — run `corepack enable`, or prefix commands with `corepack pnpm ...`. | | `ERR_PNPM_OUTDATED_LOCKFILE` | `pnpm-lock.yaml` is out of date with the manifests. Run `pnpm install` (unfrozen) and commit the lockfile update. | | `ERR_PNPM_UNSUPPORTED_ENGINE` on install | Your Node version is outside the supported 24.x engine line (`engines.node` in the root `package.json`, enforced by `engineStrict: true` in `pnpm-workspace.yaml`). Install Node 24.x (e.g. via `nvm`, `fnm` or another version manager). | -| `start` exits immediately with no output | Expected at bootstrap — the server entrypoint is a placeholder module; the Fastify 5 shell is a later story (see [Run](#run)). | +| `start` exits immediately with no output | The server crashed or exited at startup — check the process output. Since [E00-S02-T03] the entrypoint serves `GET /health` on port 3000 and stays up; a missing `pnpm build` (stale/absent `dist/`) is the usual cause (see [Run](#run)). | | `.env` files | `.env`/`.env.*` are git-ignored; a committed `.env.example` template lands with the environment story (E00-S04). | ## Out of scope diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b797de..007743e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,11 @@ importers: specifier: 6.0.3 version: 6.0.3 - apps/server: {} + apps/server: + devDependencies: + '@types/node': + specifier: 24.13.3 + version: 24.13.3 extensions/example: {} @@ -20,11 +24,23 @@ importers: packages: + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + snapshots: + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + typescript@6.0.3: {} + + undici-types@7.18.2: {} diff --git a/tests/compose-config.test.mjs b/tests/compose-config.test.mjs index a4cca8a..e72ff19 100644 --- a/tests/compose-config.test.mjs +++ b/tests/compose-config.test.mjs @@ -1,7 +1,7 @@ /** - * Docker Compose baseline test — locks in the [E00-S02-T01/T02] `docker - * compose up -d` DB + app baseline and the PostgreSQL health gate for the - * workspace. + * Docker Compose baseline test — locks in the [E00-S02-T01/T02/T03] `docker + * compose up -d` DB + app baseline, the PostgreSQL health gate and the app + * health endpoint for the workspace. * * Acceptance criteria covered (each test fails without the committed state): * - "docker compose up -d starts the database" → the committed @@ -16,10 +16,11 @@ * `compose.yaml` declares an `app` service built from the committed * `apps/server/Dockerfile` (multi-stage: Node 24.19.0 bookworm-slim + * frozen pnpm install → `tsc` build of `@personal-blog/server` → - * `node apps/server/dist/index.js`), - * with a published default port. The real-stack probe asserts the `app` - * container is created and starts cleanly (exit 0 when the placeholder - * process exits). + * `node apps/server/dist/index.js`), with a published default port. Since + * T03 the real-stack probe asserts the `app` container stays **running** + * (the server now serves the health endpoint instead of exiting) and the + * health endpoint answers HTTP 200 with a healthy body inside the + * container. * - "PostgreSQL health check gates application start" → the committed * `compose.yaml` declares a `healthcheck` on the `db` service that probes * readiness with `pg_isready` against the same credentials the database @@ -315,7 +316,7 @@ test('compose.yaml exists, parses, and declares exactly the db and app services' assert.deepEqual( Object.keys(compose.services ?? {}).sort(), ['app', 'db'], - 'compose.yaml must declare exactly the "db" and "app" services at T01', + 'compose.yaml must declare exactly the "db" and "app" services at T01/T02/T03', ); }); @@ -434,16 +435,44 @@ test('docker compose up -d starts the database and application containers', { sk const app = containers.find((c) => field(c, 'Service', 'service') === 'app'); assert.ok(app, 'docker compose up -d must create the "app" container'); const appState = String(field(app, 'State', 'state') ?? ''); - const appExit = Number(field(app, 'ExitCode', 'exit_code', 'exitCode')); - if (/exited/i.test(appState)) { - assert.equal( - appExit, - 0, - `the "app" container exited non-zero (exit ${appExit}) — the server entrypoint must start cleanly`, - ); - } else { - assert.match(appState, /running|up/i, `the "app" container must start after "docker compose up -d" (state: "${appState}")`); + assert.match( + appState, + /running|up/i, + `the "app" container must stay running after "docker compose up -d" (state: "${appState}") — since T03 the server serves the health endpoint and must not exit`, + ); + + // T03: the app serves the health endpoint — HTTP smoke test against the + // endpoint inside the app container (no host-port dependency), polling + // until it answers or times out. + let healthOutput = ''; + let healthOk = false; + for (let attempt = 0; attempt < 30 && !healthOk; attempt += 1) { + const probe = run('docker', ['compose', 'exec', '-T', 'app', 'node', '-e', ` + fetch('http://127.0.0.1:3000/health') + .then(async (res) => { console.log(res.status, await res.text()); process.exit(res.ok ? 0 : 1); }) + .catch(() => process.exit(2)); + `], { cwd: REPO_ROOT, timeout: 15_000 }); + const output = String(probe.stdout ?? '') + String(probe.stderr ?? ''); + if (probe.status === 0) { + healthOk = true; + healthOutput = output; + } else if (probe.status === 1) { + healthOutput = output; // answered but not 2xx — fail fast + break; + } else { + run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // app still starting — retry + } } + assert.ok( + healthOk, + `GET /health must answer 2xx inside the app container once the stack is up (last probe: "${healthOutput.trim()}")`, + ); + assert.match(healthOutput, /200/, `GET /health must return HTTP 200 (got: "${healthOutput.trim()}")`); + assert.match( + healthOutput, + /"status":"ok"/, + `GET /health must report a healthy application (got: "${healthOutput.trim()}")`, + ); } finally { run('docker', ['compose', 'down'], { cwd: REPO_ROOT }); } diff --git a/tests/frozen-install.test.mjs b/tests/frozen-install.test.mjs index c80e1be..4d52e06 100644 --- a/tests/frozen-install.test.mjs +++ b/tests/frozen-install.test.mjs @@ -46,13 +46,19 @@ const VIRTUAL_STORE = 'node_modules/.pnpm'; /** * Extracts the package entry keys from the `packages:` section of a pnpm 9.x * lockfile, e.g. `typescript@6.0.3` or `@scope/name@1.2.3(peer@2.0.0)`. + * pnpm 11 quotes scoped package keys in the lockfile YAML (e.g. + * `'@types/node@24.13.3':`), so surrounding single quotes are stripped to + * yield the plain `@` key the virtual-store mapping expects. */ function lockedPackageKeys(lockfileText) { const packagesSection = lockfileText.slice( lockfileText.indexOf('packages:'), lockfileText.indexOf('snapshots:'), ); - return [...packagesSection.matchAll(/^ (\S+):$/gm)].map((m) => m[1]); + return [...packagesSection.matchAll(/^ (\S+):$/gm)].map((m) => { + const key = m[1]; + return key.length >= 2 && key.startsWith("'") && key.endsWith("'") ? key.slice(1, -1) : key; + }); } /** @@ -241,4 +247,10 @@ test('the lockfile→virtual-store helpers flag missing packages (non-vacuous pr const drifted = ['typescript@6.0.4', '@scope+core@1.2.3', 'with-peer@2.0.0_peer@1.0.0']; assert.deepEqual(missingLockedDirs(keys, drifted), ['typescript@6.0.3']); + + // Scoped package keys are quoted by pnpm in the lockfile YAML (e.g. + // `'@types/node@24.13.3':`) — the extractor must strip the quotes so the + // key keeps the plain @ shape the store lookup expects. + const quotedScoped = "packages:\n\n '@scope/core@1.2.3':\n resolution: {integrity: x}\n\nsnapshots:\n"; + assert.deepEqual(lockedPackageKeys(quotedScoped), ['@scope/core@1.2.3']); }); diff --git a/tests/health-endpoint.test.mjs b/tests/health-endpoint.test.mjs new file mode 100644 index 0000000..746d286 --- /dev/null +++ b/tests/health-endpoint.test.mjs @@ -0,0 +1,232 @@ +/** + * App health endpoint test — locks in the [E00-S02-T03] `GET /health` + * endpoint for the workspace server. + * + * Acceptance criteria covered (each test fails without the committed state): + * - "app health endpoint succeeds" → the committed + * `apps/server/src/index.ts` creates an HTTP server (`node:http` + * `createServer`), listens on the application port (default 3000, + * `PORT`-overridable) and answers `GET /health` with HTTP 200. The + * "HTTP smoke test" boots the committed server source (Node type + * stripping, no build step) on an ephemeral port and makes a real + * `GET /health` request, asserting a 2xx response. + * - "the endpoint reports a healthy application" → the `/health` response + * body is JSON reporting a healthy application (`{"status":"ok"}`), + * asserted statically against the committed source and by the smoke test. + * + * Run: `node --test tests/health-endpoint.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer as createNetServer } from 'node:net'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** The committed server entrypoint under test. */ +const SERVER_SRC = 'apps/server/src/index.ts'; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// --------------------------------------------------------------------------- +// Static assertions on the committed server entrypoint +// --------------------------------------------------------------------------- + +/** + * Asserts the committed server entrypoint answers `GET /health` with HTTP 200 + * and reports a healthy application. Fails fast on a missing/placeholder + * entrypoint; the mutation probes below prove the assertions are non-vacuous. + */ +function assertHealthEndpointSource(src) { + assert.match( + src, + /createServer\(/, + 'the server entrypoint must create an HTTP server (node:http createServer)', + ); + assert.match( + src, + /'\/health'/, + "the server entrypoint must route the health endpoint (GET /health)", + ); + assert.match( + src, + /sendJson\(res, 200/, + 'the health route must answer with HTTP 200 (app health endpoint succeeds)', + ); + assert.match( + src, + /status:\s*'ok'/, + "the health payload must report a healthy application ({\"status\":\"ok\"})", + ); + assert.match( + src, + /server\.listen\(/, + 'the server entrypoint must start listening (server.listen)', + ); + assert.match( + src, + /3000/, + 'the server must default to the application port 3000 (Dockerfile EXPOSE / compose :3000)', + ); +} + +// --------------------------------------------------------------------------- +// Boot helpers for the HTTP smoke test (Node type stripping, no build step) +// --------------------------------------------------------------------------- + +/** + * How the current Node executes TypeScript sources: `default` (>= 23.6, type + * stripping on by default), `strip-types-flag` (>= 22.6 via + * `--experimental-strip-types`) or `null` (cannot run .ts at all). The + * workspace pins engines.node to 24.x, where type stripping is stable. + */ +function tsExecMode() { + const [major, minor] = process.versions.node.split('.').map(Number); + if (major > 23 || (major === 23 && minor >= 6)) return 'default'; + if (major === 22 && minor >= 6) return 'strip-types-flag'; + return null; +} + +/** Reserves an ephemeral TCP port, then releases it for the child to bind. */ +function reservePort() { + return new Promise((resolve, reject) => { + const probe = createNetServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + probe.close(() => resolve(port)); + }); + }); +} + +/** + * Boots the committed server source on `port`. Returns `{ child, stderr }`; + * the child writes its stderr into the `stderr()` closure for diagnostics. + */ +function bootServer(port) { + const args = + tsExecMode() === 'strip-types-flag' + ? ['--experimental-strip-types', SERVER_SRC] + : [SERVER_SRC]; + const child = spawn(process.execPath, args, { + cwd: REPO_ROOT, + env: { ...process.env, PORT: String(port) }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + return { child, stderr: () => stderr }; +} + +/** + * Polls `GET /health` until it answers or the child exits / the deadline + * passes (poll with timeout — no flaky sleeps). + */ +async function waitForHealth(port, child, stderr) { + const deadline = Date.now() + 10_000; + let lastError = ''; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error( + `the server exited before answering GET /health (code ${child.exitCode}): ${stderr().trim()}`, + ); + } + try { + return await fetch(`http://127.0.0.1:${port}/health`, { + signal: AbortSignal.timeout(1_000), + }); + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + await delay(100); + } + } + throw new Error( + `GET /health did not answer within 10s (last error: ${lastError}; server stderr: ${stderr().trim()})`, + ); +} + +// --------------------------------------------------------------------------- +// Criterion tests +// --------------------------------------------------------------------------- + +test('the app health endpoint succeeds (committed entrypoint answers GET /health with HTTP 200)', () => { + assertHealthEndpointSource(read(SERVER_SRC)); +}); + +test('the endpoint reports a healthy application (committed health payload is {"status":"ok"})', () => { + const src = read(SERVER_SRC); + assert.match( + src, + /status:\s*'ok'/, + "the health payload must report a healthy application ({\"status\":\"ok\"})", + ); +}); + +test('an HTTP smoke test against the booted server succeeds for GET /health (200 + healthy body)', async (t) => { + if (!tsExecMode()) { + t.skip( + `Node ${process.versions.node} cannot execute TypeScript sources; the workspace pins engines.node to 24.x (type stripping is stable there)`, + ); + return; + } + const port = await reservePort(); + const { child, stderr } = bootServer(port); + try { + const response = await waitForHealth(port, child, stderr); + assert.equal( + response.status, + 200, + `GET /health must succeed with HTTP 200 (got ${response.status})`, + ); + const body = await response.json(); + assert.equal( + body.status, + 'ok', + 'the health endpoint must report a healthy application ({"status":"ok"})', + ); + } finally { + child.kill('SIGTERM'); + await Promise.race([once(child, 'exit'), delay(2_000)]); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + } +}); + +// --------------------------------------------------------------------------- +// Non-vacuous probes — the assertions above really do fail on violations +// --------------------------------------------------------------------------- + +test('removing the /health route makes the health-endpoint criterion fail (mutation probe)', () => { + const src = read(SERVER_SRC); + const withoutRoute = src.replace(/'\/health'/, "'/nope'"); + assert.notEqual(withoutRoute, src, 'the mutation must actually replace the /health route'); + assert.throws(() => assertHealthEndpointSource(withoutRoute), /\/health/); +}); + +test('removing the HTTP 200 makes the health-endpoint criterion fail (mutation probe)', () => { + const src = read(SERVER_SRC); + const without200 = src.replace(/sendJson\(res, 200/, 'sendJson(res, 500'); + assert.notEqual(without200, src, 'the mutation must actually change the health status code'); + assert.throws(() => assertHealthEndpointSource(without200), /HTTP 200/); +}); + +test('removing the healthy report makes the healthy-report criterion fail (mutation probe)', () => { + const src = read(SERVER_SRC); + const withoutOk = src.replace(/status:\s*'ok'/, "status: 'nope'"); + assert.notEqual(withoutOk, src, 'the mutation must actually change the health payload'); + assert.throws(() => assertHealthEndpointSource(withoutOk), /healthy application/); +}); + +test('a placeholder entrypoint (no server at all) fails the health-endpoint criterion (mutation probe)', () => { + assert.throws(() => assertHealthEndpointSource('export {};\n'), /createServer/); +});