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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user