harness.ts
// The e2e harness: serves the built gallery bundle statically and launches a
// fresh headless Chrome for one suite run, and tears both down unconditionally.
//
// Scenarios know nothing about process management: they receive a connected
// CDP client and the server's base URL, navigate themselves, and assert.
// Everything here is per-run and ephemeral: fresh Chrome profile, ephemeral
// port for the static server, no shared state with anything else.
import { type ChildProcess, spawn } from "node:child_process";
import { accessSync, constants, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { createServer, type Server } from "node:http";
import { tmpdir } from "node:os";
import { extname, join, normalize } from "node:path";
import { Cdp } from "yukey-testkit/cdp";
import { sleep } from "yukey-testkit/sleep";
// The gallery's static dir, where `npm run build` in packages/yukey puts the bundle
// beside gallery.html (resolved from this file's location, packages/e2e/harness.ts).
const STATIC_DIR = join(import.meta.dirname, "..", "yukey", "static");
// The Chrome binary: headless-shell by default (what this machine has),
// overridable for other environments.
const CHROME_BIN = process.env.YUKEY_E2E_CHROME ?? "/headless-shell/headless-shell";
// The viewport for every scenario: realistic size, never a peephole.
const WINDOW_SIZE = "2000,1100";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
};
async function pollUntil(
what: string,
timeoutMs: number,
probe: () => Promise<boolean>,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await probe()) return;
await sleep(100);
}
throw new Error(`timed out waiting for ${what}`);
}
// Serves STATIC_DIR read-only on an ephemeral port. Anything that isn't a
// readable file under it is a 404 (a test server, not a product), including a
// malformed percent-escape. `..` can't escape: normalize() drops leading `..`
// from an absolute path, so the startsWith check below is a pin, not the guard.
function serveStatic(): Promise<{ baseUrl: string; server: Server }> {
const srv = createServer((req, res) => {
try {
const rel = normalize(decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname));
const file = join(STATIC_DIR, rel === "/" ? "gallery.html" : rel);
if (!file.startsWith(STATIC_DIR)) {
res.writeHead(404).end();
return;
}
const body = readFileSync(file);
res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
res.end(body);
} catch {
res.writeHead(404).end();
}
});
return new Promise((resolve, reject) => {
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address();
if (addr === null || typeof addr === "string") {
reject(new Error("static server: unexpected address shape"));
return;
}
resolve({ baseUrl: `http://127.0.0.1:${addr.port}`, server: srv });
});
srv.on("error", reject);
});
}
export class Harness {
readonly baseUrl: string;
readonly cdp: Cdp;
private readonly server: Server;
private readonly chrome: ChildProcess;
private readonly profileDir: string;
private stopped = false;
private constructor(
baseUrl: string,
cdp: Cdp,
server: Server,
chrome: ChildProcess,
profileDir: string,
) {
this.baseUrl = baseUrl;
this.cdp = cdp;
this.server = server;
this.chrome = chrome;
this.profileDir = profileDir;
}
static async start(): Promise<Harness> {
// Preflight: the bundle and the browser must exist before anything is spawned.
try {
accessSync(join(STATIC_DIR, "gallery.js"), constants.R_OK);
} catch {
throw new Error(`gallery bundle missing at ${STATIC_DIR}; run \`make build\` first`);
}
try {
accessSync(CHROME_BIN, constants.X_OK);
} catch {
throw new Error(
`chrome binary missing at ${CHROME_BIN}; `
+ "install headless-shell there, or point YUKEY_E2E_CHROME at a chromium binary",
);
}
const { baseUrl, server } = await serveStatic();
const profileDir = mkdtempSync(join(tmpdir(), "yukey-e2e-chrome-"));
const chrome = spawn(
CHROME_BIN,
[
"--headless",
"--no-sandbox",
"--disable-dev-shm-usage",
`--window-size=${WINDOW_SIZE}`,
"--remote-debugging-port=0",
`--user-data-dir=${profileDir}`,
"about:blank",
],
// Its own process group, so teardown can kill the whole Chrome tree at once.
{ stdio: "ignore", detached: true },
);
const kill = () => {
server.close();
if (chrome.pid !== undefined) {
try {
process.kill(-chrome.pid, "SIGKILL");
} catch {}
}
rmSync(profileDir, { recursive: true, force: true });
};
try {
// Chrome publishes its chosen debugging port in the profile dir.
let debugPort = 0;
await pollUntil("chrome DevToolsActivePort", 15000, async () => {
try {
const first = readFileSync(join(profileDir, "DevToolsActivePort"), "utf8").split("\n")[0];
debugPort = Number(first);
return Number.isInteger(debugPort) && debugPort > 0;
} catch {
return false;
}
});
// Find the page target and connect.
let wsUrl = "";
await pollUntil("chrome page target", 15000, async () => {
try {
const r = await fetch(`http://127.0.0.1:${debugPort}/json`);
const targets = (await r.json()) as { type: string; webSocketDebuggerUrl?: string }[];
const page = targets.find((t) => t.type === "page");
if (page?.webSocketDebuggerUrl === undefined) return false;
wsUrl = page.webSocketDebuggerUrl;
return true;
} catch {
return false;
}
});
const cdp = await Cdp.connect(wsUrl);
return new Harness(baseUrl, cdp, server, chrome, profileDir);
} catch (err) {
kill();
throw err;
}
}
// Idempotent, unconditional teardown: close the socket, stop the server,
// kill the Chrome process tree, remove the profile dir.
stop(): void {
if (this.stopped) return;
this.stopped = true;
try {
this.cdp.close();
} catch {}
this.server.close();
if (this.chrome.pid !== undefined) {
try {
process.kill(-this.chrome.pid, "SIGKILL");
} catch {}
}
rmSync(this.profileDir, { recursive: true, force: true });
}
}
packages/e2e/harness.ts · 6.1 KiB · d32b1d6ae9