refactor(frontend): rename project to s2s-frontend
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import type { ProxyOptions } from "vite";
|
||||
|
||||
/** Preserve the public origin used by FastAPI to construct the speech WebSocket URL. */
|
||||
export function createBackendProxy(target: string): Record<string, ProxyOptions> {
|
||||
const http: ProxyOptions = { target, changeOrigin: false };
|
||||
return {
|
||||
"/api": http,
|
||||
"/v1/realtime": { ...http, ws: true },
|
||||
"/docs": http,
|
||||
"/redoc": http,
|
||||
"/openapi.json": http,
|
||||
"/oauth": http,
|
||||
"/project": http,
|
||||
"/public/docs.html": http,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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, "s2s-frontend");
|
||||
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",
|
||||
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);
|
||||
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")));
|
||||
});
|
||||
Reference in New Issue
Block a user