bd7b166fda
Mirrors the existing nestsClient/tests/hang-interop/ layout — all T16 cross-stack interop test infrastructure (Rust sidecars + bun browser harness) now lives under nestsClient/tests/. Updates: - nestsClient/build.gradle.kts: browserInteropDir path. - PlaywrightDriver.kt + BrowserInteropTest.kt: kdoc comments. - All plan docs in nestsClient/plans/ that referenced the old path. Compiles clean post-move. No CI changes (those jobs are intentionally not wired per 2026-05-07-cross-stack-interop-ci-gating.md; when re-added they'll reference the new path).
137 lines
4.8 KiB
TypeScript
137 lines
4.8 KiB
TypeScript
// Phase 4 (T16) bun static + WebSocket back-channel server.
|
|
//
|
|
// One process per Kotlin test, bound to a random port; the PlaywrightDriver
|
|
// passes the port back to the harness pages as `?wsPort=…`. PCM frames sent
|
|
// over the WS as binary messages get appended to `--out-pcm`. A textual
|
|
// `done` message flips the server's `done` flag so the test driver can
|
|
// poll it via the `/state` endpoint and tear down cleanly.
|
|
//
|
|
// Argv:
|
|
// --port <int> listen port; 0 picks a random one (logged on stdout)
|
|
// --root <dir> directory to serve static files from (= dist/)
|
|
// --out-pcm <path> file to append received PCM frames to
|
|
//
|
|
// Stdout (machine-readable, single line then blank line):
|
|
// port=<int>
|
|
// ready
|
|
//
|
|
// Errors go to stderr; non-zero exit code on fatal startup failure.
|
|
|
|
import { type ServerWebSocket } from "bun";
|
|
import { mkdirSync, openSync, closeSync, writeSync, existsSync } from "node:fs";
|
|
import { dirname, resolve, join } from "node:path";
|
|
|
|
interface Args {
|
|
port: number;
|
|
root: string;
|
|
outPcm: string;
|
|
}
|
|
|
|
function parseArgs(): Args {
|
|
const args = process.argv.slice(2);
|
|
let port = 0;
|
|
let root = "";
|
|
let outPcm = "";
|
|
for (let i = 0; i < args.length; i++) {
|
|
const a = args[i];
|
|
if (a === "--port") port = Number(args[++i]);
|
|
else if (a === "--root") root = args[++i];
|
|
else if (a === "--out-pcm") outPcm = args[++i];
|
|
else throw new Error(`unknown arg: ${a}`);
|
|
}
|
|
if (!root) throw new Error("--root is required");
|
|
if (!outPcm) throw new Error("--out-pcm is required");
|
|
return { port, root, outPcm: resolve(outPcm) };
|
|
}
|
|
|
|
const args = parseArgs();
|
|
mkdirSync(dirname(args.outPcm), { recursive: true });
|
|
// Truncate any prior file: the harness page reopens each run.
|
|
const fd = openSync(args.outPcm, "w");
|
|
|
|
let done = false;
|
|
const wsClients = new Set<ServerWebSocket<unknown>>();
|
|
|
|
const contentType = (path: string): string => {
|
|
if (path.endsWith(".html")) return "text/html; charset=utf-8";
|
|
if (path.endsWith(".js")) return "application/javascript; charset=utf-8";
|
|
if (path.endsWith(".mjs")) return "application/javascript; charset=utf-8";
|
|
if (path.endsWith(".json")) return "application/json; charset=utf-8";
|
|
if (path.endsWith(".css")) return "text/css; charset=utf-8";
|
|
if (path.endsWith(".wasm")) return "application/wasm";
|
|
return "application/octet-stream";
|
|
};
|
|
|
|
const server = Bun.serve({
|
|
port: args.port,
|
|
hostname: "127.0.0.1",
|
|
fetch(req, srv) {
|
|
const url = new URL(req.url);
|
|
if (url.pathname === "/pcm") {
|
|
// WebSocket upgrade for the PCM back-channel.
|
|
if (srv.upgrade(req)) return;
|
|
return new Response("expected websocket upgrade", { status: 400 });
|
|
}
|
|
if (url.pathname === "/state") {
|
|
return new Response(JSON.stringify({ done }), {
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
// Static file serve out of root/.
|
|
let path = url.pathname === "/" ? "/listen.html" : url.pathname;
|
|
const filePath = join(args.root, path.replace(/^\/+/, ""));
|
|
// Reject path traversal attempts.
|
|
if (!filePath.startsWith(resolve(args.root))) {
|
|
return new Response("forbidden", { status: 403 });
|
|
}
|
|
if (!existsSync(filePath)) {
|
|
return new Response("not found: " + path, { status: 404 });
|
|
}
|
|
const file = Bun.file(filePath);
|
|
return new Response(file, {
|
|
headers: {
|
|
"content-type": contentType(filePath),
|
|
// No-cache so a `bun build` rebuild between Playwright runs
|
|
// is picked up immediately.
|
|
"cache-control": "no-store",
|
|
},
|
|
});
|
|
},
|
|
websocket: {
|
|
message(ws, message) {
|
|
wsClients.add(ws);
|
|
if (typeof message === "string") {
|
|
if (message === "done") {
|
|
done = true;
|
|
console.log("[server] received `done`");
|
|
}
|
|
return;
|
|
}
|
|
// Binary PCM frame — append raw bytes to the out file.
|
|
const buf = message instanceof ArrayBuffer ? new Uint8Array(message) : new Uint8Array(message.buffer, message.byteOffset, message.byteLength);
|
|
writeSync(fd, buf);
|
|
},
|
|
open(ws) {
|
|
wsClients.add(ws);
|
|
},
|
|
close(ws) {
|
|
wsClients.delete(ws);
|
|
},
|
|
},
|
|
});
|
|
|
|
// Machine-readable handshake for PlaywrightDriver.
|
|
process.stdout.write(`port=${server.port}\n`);
|
|
process.stdout.write("ready\n");
|
|
|
|
// Clean up the fd on Ctrl-C / parent kill so we don't leak it.
|
|
const shutdown = () => {
|
|
try {
|
|
closeSync(fd);
|
|
} catch { /* ignore */ }
|
|
server.stop(true);
|
|
process.exit(0);
|
|
};
|
|
process.on("SIGINT", shutdown);
|
|
process.on("SIGTERM", shutdown);
|