diff --git a/.replit b/.replit index dc867db8..9e1fb1da 100644 --- a/.replit +++ b/.replit @@ -29,7 +29,7 @@ outputType = "console" [[workflows.workflow.tasks]] 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] stack = "PNPM_WORKSPACE" @@ -55,6 +55,10 @@ externalPort = 3003 localPort = 8083 externalPort = 5173 +[[ports]] +localPort = 8089 +externalPort = 3001 + [[ports]] localPort = 25785 externalPort = 3000 diff --git a/README.md b/README.md index ba4ea686..d7ef373d 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 71ccd91d..2996c78c 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -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": { diff --git a/artifacts/api-server/scripts/with-server.mjs b/artifacts/api-server/scripts/with-server.mjs new file mode 100644 index 00000000..aa7b3f4c --- /dev/null +++ b/artifacts/api-server/scripts/with-server.mjs @@ -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 -- [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 -- [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); +}); diff --git a/package.json b/package.json index 2d2a21a4..5bda7e90 100644 --- a/package.json +++ b/package.json @@ -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": {