refactor: rename API and frontend projects to Vocero
Rename the project directories, Python distribution and import package, and npm package. Update launcher, Docker, tooling, tests, lockfiles, and documentation while preserving existing API contracts. Make AGENTS.md self-contained and remove external skill and workflow references.
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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,
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
}));
|
||||
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, "vocero-frontend");
|
||||
const modules = join(frontend, "node_modules");
|
||||
await mkdir(join(root, "vocero-api/.venv/bin"), { recursive: true });
|
||||
await mkdir(join(root, "bin"));
|
||||
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, "vocero-api/pyproject.toml"), "");
|
||||
await writeFile(join(root, "vocero-api/uv.lock"), "");
|
||||
await writeFile(
|
||||
join(root, "bin/uv"),
|
||||
'#!/bin/bash\nprintf "%s\\n" "$@" > "$S2S_TEST_ROOT/uv-args"\nprintf "%s" "$UV_PROJECT_ENVIRONMENT" > "$S2S_TEST_ROOT/uv-environment"\nexit "${S2S_TEST_UV_EXIT:-0}"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "vocero-api/.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,
|
||||
PATH: `${join(root, "bin")}:${process.env.PATH ?? ""}`,
|
||||
UI_PORT: String(publicPort),
|
||||
UI_HOST: "0.0.0.0",
|
||||
NODE_ENV: "production",
|
||||
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",
|
||||
nodeEnv: "development",
|
||||
});
|
||||
assert.equal(run.output().includes("Installing"), false);
|
||||
assert.equal(
|
||||
await readFile(join(run.root, "uv-args"), "utf8"),
|
||||
`sync\n--project\n${run.root}/vocero-api\n--locked\n`,
|
||||
);
|
||||
assert.equal(await readFile(join(run.root, "uv-environment"), "utf8"), `${run.root}/vocero-api/.venv`);
|
||||
const backendMetadata: unknown = JSON.parse(await readFile(join(run.root, "backend.json"), "utf8"));
|
||||
assert.deepEqual(backendMetadata, {
|
||||
args: [
|
||||
"backend",
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"vocero_api.main:app",
|
||||
"--app-dir",
|
||||
`${run.root}/vocero-api`,
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(run.backendPort),
|
||||
"--reload",
|
||||
"--reload-dir",
|
||||
`${run.root}/vocero-api`,
|
||||
],
|
||||
target: `http://127.0.0.1:${run.backendPort}`,
|
||||
hosts: "s2s.test",
|
||||
nodeEnv: "production",
|
||||
});
|
||||
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("does not start either server when uv sync fails", { timeout: 10_000 }, async (context) => {
|
||||
const run = await launcher(context, { S2S_TEST_UV_EXIT: "23" });
|
||||
assert.equal(await run.exited, 23, run.output());
|
||||
await assert.rejects(readFile(join(run.root, "backend.json")));
|
||||
await assert.rejects(readFile(join(run.root, "frontend.json")));
|
||||
});
|
||||
|
||||
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")));
|
||||
});
|
||||
Reference in New Issue
Block a user