241 lines
9.0 KiB
JavaScript
241 lines
9.0 KiB
JavaScript
/**
|
|
* 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.
|
|
*
|
|
* The child boots WITHOUT `DATABASE_URL` (it is stripped from the inherited
|
|
* env): since E00-S03-T06 the health endpoint is the readiness probe, and the
|
|
* no-DATABASE_URL path is the one with no startup migration run to wait for —
|
|
* the server reports ready immediately, so this smoke test stays deterministic
|
|
* and exercises exactly the committed no-migration readiness path.
|
|
*/
|
|
function bootServer(port) {
|
|
const args =
|
|
tsExecMode() === 'strip-types-flag'
|
|
? ['--experimental-strip-types', SERVER_SRC]
|
|
: [SERVER_SRC];
|
|
const env = { ...process.env, PORT: String(port) };
|
|
delete env.DATABASE_URL;
|
|
const child = spawn(process.execPath, args, {
|
|
cwd: REPO_ROOT,
|
|
env,
|
|
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/);
|
|
});
|