From 65c965ae6be28907158cec2bf9dd9fd3d8ff21f2 Mon Sep 17 00:00:00 2001 From: Alvaro Parker Date: Tue, 6 Oct 2026 16:50:07 -0300 Subject: [PATCH] test(dev): cover launcher readiness failures and process cleanup --- frontend-s2s/config/launcher.integration.ts | 184 ++++++++++++++++++++ frontend-s2s/package.json | 3 +- 2 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 frontend-s2s/config/launcher.integration.ts diff --git a/frontend-s2s/config/launcher.integration.ts b/frontend-s2s/config/launcher.integration.ts new file mode 100644 index 0000000..0aeeb66 --- /dev/null +++ b/frontend-s2s/config/launcher.integration.ts @@ -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((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 = {}) { + 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((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"))); +}); diff --git a/frontend-s2s/package.json b/frontend-s2s/package.json index 372259f..c61a38a 100644 --- a/frontend-s2s/package.json +++ b/frontend-s2s/package.json @@ -17,7 +17,8 @@ "typecheck": "tsc -b", "test": "vitest run", "test:watch": "vitest", - "check": "npm run format:check && npm run lint && npm run typecheck && npm test" + "test:launcher": "node --test config/launcher.integration.ts", + "check": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run test:launcher" }, "dependencies": { "dompurify": "^3.4.16",