[E00-S02-T03] App health endpoint succeeds #384
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+17
-1
@@ -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: {}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 `<name>@<version>` 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 <name>@<version> 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']);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user