Improve test execution by automatically starting and stopping the API server

Refactor test execution to use a new script that manages the API server lifecycle, including building, starting, waiting for health, running tests, and shutting down, with support for using an existing server.
This commit is contained in:
Riyadh
2026-05-15 10:48:48 +00:00
parent 86ee61c0e5
commit 7396c4c1af
4 changed files with 386 additions and 8 deletions
+20 -6
View File
@@ -239,15 +239,29 @@ The route layer is unaware of which driver is active — both expose the same
## Tests
```bash
pnpm --filter @workspace/api-server build
pnpm --filter @workspace/api-server start & # boot API on 8080
pnpm --filter @workspace/api-server test # node --test suite
pnpm test # full suite, auto-boots API
pnpm --filter @workspace/api-server test # api-server tests only
pnpm --filter @workspace/tx-os test:e2e # Playwright UI tests
```
The `test` workflow chains all of the above. Tests use the same database
specified in `DATABASE_URL` and clean up after themselves with `LIKE`-prefixed
fixture rows.
Both `pnpm test` and `pnpm --filter @workspace/api-server test` go through
`artifacts/api-server/scripts/with-server.mjs`, which builds the API server,
spawns it on `PORT` (default `8080`), waits for `/api/healthz`, runs the
wrapped command with `TEST_API_BASE` exported, and tears the server down on
exit (including `Ctrl+C` and failures). If startup fails the helper prints
the tail of the server's stderr/stdout so the root cause is visible.
To run the tests against an already-running API server (e.g. the dev
workflow), set `TEST_API_BASE` before invoking the script — the helper will
skip spawning and use that URL instead:
```bash
TEST_API_BASE=http://localhost:8080 pnpm --filter @workspace/api-server test
```
Tests use the same database specified in `DATABASE_URL` and clean up after
themselves with `LIKE`-prefixed fixture rows. The `test:wait` script remains
available for anyone who only wants the bare health-check wait behaviour.
---
+2 -1
View File
@@ -9,7 +9,8 @@
"start": "node --enable-source-maps ./dist/index.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test:wait": "node ./scripts/wait-for-server.mjs",
"test": "node --test 'tests/*.test.mjs' && node --test --test-concurrency=1 'tests/serial/**/*.test.mjs'",
"test:run": "node --test 'tests/*.test.mjs' && node --test --test-concurrency=1 'tests/serial/**/*.test.mjs'",
"test": "node ./scripts/with-server.mjs -- pnpm run test:run",
"migrate": "pnpm --filter @workspace/db run push && pnpm --filter scripts run seed"
},
"dependencies": {
@@ -0,0 +1,363 @@
#!/usr/bin/env node
// Spawns the built API server, waits for /api/healthz, runs a wrapped
// command, and tears the server down on exit. If TEST_API_BASE is set in
// the environment, the spawn is skipped and the wrapped command runs
// against that externally-managed server. If a healthy API server is
// already running on the target port (e.g. the dev API Server workflow),
// the helper reuses it instead of trying to bind a second process.
//
// Usage:
// node ./scripts/with-server.mjs -- <command> [args...]
//
// Env knobs:
// TEST_API_BASE If set, skip spawning and use this URL.
// PORT Port to bind the spawned server to (default 8080).
// TEST_API_WAIT_TIMEOUT_MS Health-wait timeout (default 60000 here).
// TEST_API_WAIT_INTERVAL_MS Poll interval (default 500).
// API_SERVER_LOG_TAIL Number of stderr/stdout lines to dump on
// startup failure (default 60).
// WITH_SERVER_FORCE_BUILD=1 Force a rebuild even if dist/index.mjs exists.
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const apiServerDir = path.resolve(scriptDir, "..");
function parseArgs(argv) {
const sep = argv.indexOf("--");
const rest = sep === -1 ? argv : argv.slice(sep + 1);
if (rest.length === 0) {
console.error(
"with-server.mjs: missing wrapped command. Usage: with-server.mjs -- <cmd> [args...]",
);
process.exit(2);
}
return { cmd: rest[0], cmdArgs: rest.slice(1) };
}
function tail(buffer, n) {
const lines = buffer.split(/\r?\n/);
return lines.slice(-n).join("\n");
}
async function isAlreadyHealthy(url) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1_500);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!res.ok) return false;
const body = await res.json().catch(() => ({}));
return body && body.status === "ok";
} catch {
return false;
}
}
async function waitForHealth(url, timeoutMs, intervalMs, isChildAlive) {
const started = Date.now();
let attempt = 0;
let lastError = null;
while (Date.now() - started < timeoutMs) {
if (!isChildAlive()) {
return {
ok: false,
reason: "API server child exited before becoming healthy",
lastError,
attempts: attempt,
};
}
attempt += 1;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2_000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (res.ok) {
const body = await res.json().catch(() => ({}));
if (body && body.status === "ok") {
return { ok: true, attempts: attempt, elapsedMs: Date.now() - started };
}
lastError = new Error(`Unexpected health payload: ${JSON.stringify(body)}`);
} else {
lastError = new Error(`HTTP ${res.status} from ${url}`);
}
} catch (err) {
lastError = err;
}
await new Promise((r) => setTimeout(r, intervalMs));
}
return {
ok: false,
reason: `API server did not become healthy within ${timeoutMs}ms`,
lastError,
attempts: attempt,
};
}
// Centralized lifecycle so signal handlers, top-level errors, and the
// happy-path exit all funnel through the same shutdown sequence. This
// guarantees the spawned API server is killed exactly once and that
// repeated signals never short-circuit an in-flight teardown.
function createLifecycle() {
let server = null;
let serverExited = false;
let serverExitCode = null;
let serverExitSignal = null;
let shutdownPromise = null;
let activeChild = null;
function setServer(proc) {
server = proc;
server.on("exit", (code, signal) => {
serverExited = true;
serverExitCode = code;
serverExitSignal = signal;
});
}
function setActiveChild(proc) {
activeChild = proc;
}
function clearActiveChild(proc) {
if (activeChild === proc) activeChild = null;
}
function shutdown(signal = "SIGTERM") {
if (shutdownPromise) return shutdownPromise;
shutdownPromise = (async () => {
// Forward signal to wrapped command first so it can flush.
if (activeChild && !activeChild.killed) {
try {
activeChild.kill(signal);
} catch {
/* noop */
}
}
if (!server || serverExited) return;
await new Promise((resolve) => {
const onExit = () => resolve();
server.once("exit", onExit);
try {
server.kill(signal);
} catch {
server.removeListener("exit", onExit);
resolve();
return;
}
const force = setTimeout(() => {
try {
if (!serverExited) server.kill("SIGKILL");
} catch {
/* noop */
}
}, 5_000);
server.once("exit", () => clearTimeout(force));
});
})();
return shutdownPromise;
}
return {
setServer,
setActiveChild,
clearActiveChild,
shutdown,
get serverExited() {
return serverExited;
},
get serverExitCode() {
return serverExitCode;
},
get serverExitSignal() {
return serverExitSignal;
},
};
}
function runWrappedCommand(cmd, cmdArgs, env, lifecycle) {
return new Promise((resolve) => {
const child = spawn(cmd, cmdArgs, {
stdio: "inherit",
env,
shell: false,
});
if (lifecycle) lifecycle.setActiveChild(child);
child.on("exit", (code, signal) => {
if (lifecycle) lifecycle.clearActiveChild(child);
resolve({ code: code ?? (signal ? 1 : 0), signal });
});
child.on("error", (err) => {
if (lifecycle) lifecycle.clearActiveChild(child);
console.error(`with-server.mjs: failed to spawn wrapped command: ${err.message}`);
resolve({ code: 1, signal: null });
});
});
}
function buildIfNeeded() {
const distEntry = path.join(apiServerDir, "dist", "index.mjs");
if (existsSync(distEntry) && process.env.WITH_SERVER_FORCE_BUILD !== "1") {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const child = spawn("node", ["./build.mjs"], {
cwd: apiServerDir,
stdio: "inherit",
env: process.env,
});
child.on("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`API server build failed with exit code ${code}`));
});
child.on("error", (err) => reject(err));
});
}
async function main() {
const { cmd, cmdArgs } = parseArgs(process.argv.slice(2));
// Externally-managed server: skip spawn entirely.
if (process.env.TEST_API_BASE) {
console.log(
`with-server.mjs: TEST_API_BASE=${process.env.TEST_API_BASE} set; using existing server.`,
);
const { code } = await runWrappedCommand(cmd, cmdArgs, process.env, null);
process.exit(code);
}
const port = process.env.PORT ?? "8080";
const apiBase = `http://localhost:${port}`;
const healthUrl = `${apiBase}/api/healthz`;
if (await isAlreadyHealthy(healthUrl)) {
console.log(
`with-server.mjs: detected healthy API server at ${healthUrl}; reusing it (skipping spawn).`,
);
const reuseEnv = { ...process.env, TEST_API_BASE: apiBase };
const { code } = await runWrappedCommand(cmd, cmdArgs, reuseEnv, null);
process.exit(code);
}
await buildIfNeeded();
const tailLines = Number(process.env.API_SERVER_LOG_TAIL ?? 60);
const timeoutMs = Number(process.env.TEST_API_WAIT_TIMEOUT_MS ?? 60_000);
const intervalMs = Number(process.env.TEST_API_WAIT_INTERVAL_MS ?? 500);
const serverEnv = {
...process.env,
PORT: port,
NODE_ENV: process.env.NODE_ENV ?? "development",
};
const lifecycle = createLifecycle();
// Install centralized signal/error handlers ONCE so repeated signals
// share the same in-flight shutdown promise instead of racing.
const onSignal = (sig) => {
lifecycle.shutdown(sig).finally(() => {
const exitCode = sig === "SIGINT" ? 130 : 143;
process.exit(exitCode);
});
};
process.on("SIGINT", () => onSignal("SIGINT"));
process.on("SIGTERM", () => onSignal("SIGTERM"));
process.on("uncaughtException", async (err) => {
console.error("with-server.mjs: uncaught exception", err);
try {
await lifecycle.shutdown("SIGTERM");
} finally {
process.exit(1);
}
});
process.on("unhandledRejection", async (err) => {
console.error("with-server.mjs: unhandled rejection", err);
try {
await lifecycle.shutdown("SIGTERM");
} finally {
process.exit(1);
}
});
console.log(`with-server.mjs: starting API server on ${apiBase} ...`);
const server = spawn("node", ["--enable-source-maps", "./dist/index.mjs"], {
cwd: apiServerDir,
env: serverEnv,
stdio: ["ignore", "pipe", "pipe"],
});
lifecycle.setServer(server);
let stdoutBuf = "";
let stderrBuf = "";
const MAX_BUF = 256 * 1024;
server.stdout.on("data", (chunk) => {
stdoutBuf += chunk.toString();
if (stdoutBuf.length > MAX_BUF) stdoutBuf = stdoutBuf.slice(-MAX_BUF);
process.stdout.write(chunk);
});
server.stderr.on("data", (chunk) => {
stderrBuf += chunk.toString();
if (stderrBuf.length > MAX_BUF) stderrBuf = stderrBuf.slice(-MAX_BUF);
process.stderr.write(chunk);
});
const health = await waitForHealth(
healthUrl,
timeoutMs,
intervalMs,
() => !lifecycle.serverExited,
);
if (!health.ok) {
console.error(`\nwith-server.mjs: ${health.reason}`);
if (health.lastError) {
console.error(
`Last health-check error: ${health.lastError.message ?? health.lastError}`,
);
}
if (lifecycle.serverExited) {
console.error(
`API server exited prematurely (code=${lifecycle.serverExitCode}, signal=${lifecycle.serverExitSignal}).`,
);
}
if (stderrBuf.trim()) {
console.error(`\n----- API server stderr (last ${tailLines} lines) -----`);
console.error(tail(stderrBuf, tailLines));
console.error(`----- end stderr -----`);
}
if (!stderrBuf.trim() && stdoutBuf.trim()) {
console.error(`\n----- API server stdout (last ${tailLines} lines) -----`);
console.error(tail(stdoutBuf, tailLines));
console.error(`----- end stdout -----`);
}
console.error(
"\nHint: API server failed to start. Check the logs above, or set TEST_API_BASE to point at a running instance.",
);
await lifecycle.shutdown("SIGTERM");
process.exit(1);
}
console.log(
`with-server.mjs: API server ready at ${healthUrl} after ${health.elapsedMs}ms (attempt ${health.attempts}).`,
);
const wrappedEnv = { ...process.env, TEST_API_BASE: apiBase };
const { code, signal } = await runWrappedCommand(cmd, cmdArgs, wrappedEnv, lifecycle);
await lifecycle.shutdown("SIGTERM");
if (signal) {
process.exit(128 + (signal === "SIGINT" ? 2 : 15));
}
process.exit(code ?? 0);
}
main().catch(async (err) => {
console.error("with-server.mjs: unexpected error", err);
process.exit(1);
});
+1 -1
View File
@@ -7,7 +7,7 @@
"build": "pnpm run typecheck && pnpm -r --if-present run build",
"typecheck:libs": "tsc --build",
"typecheck": "pnpm run typecheck:libs && pnpm -r --filter \"./artifacts/**\" --filter \"./scripts\" --if-present run typecheck",
"test": "pnpm --filter @workspace/api-server test && pnpm --filter @workspace/scripts test && pnpm --filter @workspace/tx-os test:e2e"
"test": "node artifacts/api-server/scripts/with-server.mjs -- sh -c 'pnpm --filter @workspace/api-server run test:run && pnpm --filter @workspace/scripts test && pnpm --filter @workspace/tx-os test:e2e'"
},
"private": true,
"devDependencies": {