test(dev): cover launcher readiness failures and process cleanup

This commit is contained in:
2026-10-06 16:50:07 -03:00
parent cb09e75bbd
commit 65c965ae6b
2 changed files with 186 additions and 1 deletions
+184
View File
@@ -0,0 +1,184 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test, type TestContext } from "node:test";
import { setTimeout as delay } from "node:timers/promises";
// Exercise the real Bash supervisor with lightweight children, without installing
// packages, starting upstream services, or reading the checkout's private .env.
const childSource = `
import { createServer } from "node:http";
import { writeFileSync } from "node:fs";
import { spawn } from "node:child_process";
const role = process.argv[2] === "backend" ? "backend" : "frontend";
const root = process.env.S2S_TEST_ROOT;
const args = process.argv.slice(2);
writeFileSync(root + "/" + role + ".pid", String(process.pid));
writeFileSync(root + "/" + role + ".json", JSON.stringify({
args, target: process.env.S2S_BACKEND_URL, hosts: process.env.S2S_DEV_ALLOWED_HOSTS,
}));
if (process.env.S2S_TEST_FAIL === role) process.exit(37);
const worker = spawn(process.execPath, ["-e",
'const fs = require("node:fs"); process.on("SIGTERM", () => { fs.writeFileSync(process.argv[1], "yes"); process.exit(0); }); fs.writeFileSync(process.argv[2], "yes"); setInterval(() => {}, 1000);',
root + "/" + role + "-worker-stopped", root + "/" + role + "-worker-ready",
], { stdio: "ignore" });
process.on("SIGTERM", () => {
worker.once("exit", () => process.exit(0));
if (worker.exitCode !== null || worker.signalCode !== null) process.exit(0);
});
createServer((request, response) => {
response.setHeader("Content-Type", "application/json");
response.end("{}");
}).listen(Number(args[args.indexOf("--port") + 1]), "127.0.0.1");
`;
async function freePort() {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert.ok(address && typeof address === "object");
await new Promise<void>((resolve) =>
server.close(() => {
resolve();
}),
);
return address.port;
}
async function waitForFile(filename: string) {
for (let attempt = 0; attempt < 100; attempt++) {
try {
return await readFile(filename, "utf8");
} catch {
await delay(30);
}
}
assert.fail(`Timed out waiting for ${filename}`);
}
async function launcher(context: TestContext, env: Record<string, string> = {}) {
const root = await mkdtemp(join(tmpdir(), "s2s-launcher-"));
const frontend = join(root, "frontend-s2s");
const modules = join(frontend, "node_modules");
await mkdir(join(root, ".venv/bin"), { recursive: true });
await mkdir(join(root, "s2s-client"));
await mkdir(join(modules, "vite/bin"), { recursive: true });
await copyFile(new URL("../../start_ui.sh", import.meta.url), join(root, "start_ui.sh"));
await writeFile(join(root, "s2s-client/requirements.txt"), "");
await writeFile(join(root, ".venv/bin/activate"), 'export PATH="$S2S_TEST_ROOT/.venv/bin:$PATH"\n');
await writeFile(
join(root, ".venv/bin/python"),
'#!/bin/bash\nif [ "$1" = "-c" ]; then exit 0; fi\nexec node "$S2S_TEST_ROOT/child.mjs" backend "$@"\n',
{ mode: 0o755 },
);
await writeFile(join(root, "child.mjs"), childSource);
await writeFile(join(modules, "vite/bin/vite.js"), 'import(process.env.S2S_TEST_ROOT + "/child.mjs");\n');
for (const manifest of ["package.json", "package-lock.json"]) {
await writeFile(join(frontend, manifest), "{}\n");
await copyFile(join(frontend, manifest), join(modules, `.s2s-${manifest}`));
}
const publicPort = await freePort();
let backendPort = await freePort();
while (backendPort === publicPort) {
backendPort = await freePort();
}
// The root .env retains the launcher's existing precedence over shell values.
await writeFile(join(root, ".env"), `S2S_DEV_ALLOWED_HOSTS=s2s.test\nS2S_BACKEND_PORT=${backendPort}\n`);
const child = spawn("bash", [join(root, "start_ui.sh")], {
cwd: tmpdir(),
env: {
...process.env,
UI_PORT: String(publicPort),
UI_HOST: "0.0.0.0",
S2S_BACKEND_PORT: "1",
S2S_BACKEND_URL: "http://wrong-target.invalid",
S2S_TEST_ROOT: root,
S2S_TEST_FAIL: "",
...env,
},
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
const exited = new Promise<number | null>((resolve) => child.once("close", resolve));
context.after(async () => {
child.kill("SIGTERM");
await exited;
await rm(root, { recursive: true, force: true });
});
return { root, child, exited, publicPort, backendPort, output: () => output };
}
for (const signal of ["SIGINT", "SIGTERM"] as const) {
await test(
`keeps the public endpoint and stops both process groups on ${signal}`,
{ timeout: 10_000 },
async (context) => {
const run = await launcher(context);
for (const role of ["backend", "frontend"]) {
await waitForFile(join(run.root, `${role}-worker-ready`));
}
const metadata: unknown = JSON.parse(await readFile(join(run.root, "frontend.json"), "utf8"));
assert.deepEqual(metadata, {
args: ["--host", "0.0.0.0", "--port", String(run.publicPort), "--strictPort"],
target: `http://127.0.0.1:${run.backendPort}`,
hosts: "s2s.test",
});
assert.equal(run.output().includes("Installing"), false);
run.child.kill(signal);
assert.equal(await run.exited, signal === "SIGINT" ? 130 : 143);
for (const role of ["backend", "frontend"]) {
assert.equal(await waitForFile(join(run.root, `${role}-worker-stopped`)), "yes");
}
for (const port of [run.publicPort, run.backendPort]) {
await assert.rejects(fetch(`http://127.0.0.1:${port}`));
}
},
);
}
await test(
"stops the backend if Vite exits and returns the failing exit code",
{ timeout: 10_000 },
async (context) => {
const run = await launcher(context, { S2S_TEST_FAIL: "frontend" });
assert.equal(await run.exited, 37, run.output());
assert.equal(await waitForFile(join(run.root, "backend-worker-stopped")), "yes");
await assert.rejects(fetch(`http://127.0.0.1:${run.backendPort}`));
},
);
await test("does not start Vite when the backend fails readiness", { timeout: 10_000 }, async (context) => {
const run = await launcher(context, { S2S_TEST_FAIL: "backend" });
assert.equal(await run.exited, 1, run.output());
await assert.rejects(readFile(join(run.root, "frontend.json")));
assert.match(run.output(), /FastAPI exited before it was ready/);
});
await test("stops Vite if the backend exits after startup", { timeout: 10_000 }, async (context) => {
const run = await launcher(context);
await waitForFile(join(run.root, "frontend-worker-ready"));
const backendPid = Number(await readFile(join(run.root, "backend.pid"), "utf8"));
assert.ok(Number.isInteger(backendPid) && backendPid > 0);
process.kill(-backendPid, "SIGTERM");
assert.equal(await run.exited, 1, run.output());
assert.equal(await waitForFile(join(run.root, "frontend-worker-stopped")), "yes");
await assert.rejects(fetch(`http://127.0.0.1:${run.publicPort}`));
});
await test("rejects an invalid public port before starting either server", { timeout: 10_000 }, async (context) => {
const run = await launcher(context, { UI_PORT: "65536" });
assert.equal(await run.exited, 1);
assert.match(run.output(), /UI_PORT must be a port/);
await assert.rejects(readFile(join(run.root, "backend.json")));
});