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.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: a42bd3b2-3145-4118-87aa-336a7e2189ca
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/FvHcc7z
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
riyadhafraa
2026-05-15 10:48:48 +00:00
parent 77f8096deb
commit a5a35ef217
5 changed files with 391 additions and 9 deletions
+5 -1
View File
@@ -29,7 +29,7 @@ outputType = "console"
[[workflows.workflow.tasks]] [[workflows.workflow.tasks]]
task = "shell.exec" task = "shell.exec"
args = "pnpm --filter @workspace/api-server test:wait && pnpm --filter @workspace/api-server test && pnpm --filter @workspace/tx-os test:e2e" args = "pnpm test"
[agent] [agent]
stack = "PNPM_WORKSPACE" stack = "PNPM_WORKSPACE"
@@ -55,6 +55,10 @@ externalPort = 3003
localPort = 8083 localPort = 8083
externalPort = 5173 externalPort = 5173
[[ports]]
localPort = 8089
externalPort = 3001
[[ports]] [[ports]]
localPort = 25785 localPort = 25785
externalPort = 3000 externalPort = 3000
+20 -6
View File
@@ -239,15 +239,29 @@ The route layer is unaware of which driver is active — both expose the same
## Tests ## Tests
```bash ```bash
pnpm --filter @workspace/api-server build pnpm test # full suite, auto-boots API
pnpm --filter @workspace/api-server start & # boot API on 8080 pnpm --filter @workspace/api-server test # api-server tests only
pnpm --filter @workspace/api-server test # node --test suite
pnpm --filter @workspace/tx-os test:e2e # Playwright UI tests pnpm --filter @workspace/tx-os test:e2e # Playwright UI tests
``` ```
The `test` workflow chains all of the above. Tests use the same database Both `pnpm test` and `pnpm --filter @workspace/api-server test` go through
specified in `DATABASE_URL` and clean up after themselves with `LIKE`-prefixed `artifacts/api-server/scripts/with-server.mjs`, which builds the API server,
fixture rows. 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", "start": "node --enable-source-maps ./dist/index.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"test:wait": "node ./scripts/wait-for-server.mjs", "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" "migrate": "pnpm --filter @workspace/db run push && pnpm --filter scripts run seed"
}, },
"dependencies": { "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", "build": "pnpm run typecheck && pnpm -r --if-present run build",
"typecheck:libs": "tsc --build", "typecheck:libs": "tsc --build",
"typecheck": "pnpm run typecheck:libs && pnpm -r --filter \"./artifacts/**\" --filter \"./scripts\" --if-present run typecheck", "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, "private": true,
"devDependencies": { "devDependencies": {