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 { 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 kill: () => void;
private constructor(baseUrl: string, cdp: Cdp, kill: () => void) {
this.baseUrl = baseUrl;
this.cdp = cdp;
this.kill = kill;
}
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 },
);
// Unconditional, idempotent teardown of everything spawned above: stop the
// server, kill the Chrome tree, remove the profile dir. Every exit path
// below funnels through it, including stop().
let killed = false;
const kill = () => {
if (killed) return;
killed = true;
server.close();
if (chrome.pid !== undefined) {
try {
process.kill(-chrome.pid, "SIGKILL");
} catch {}
}
rmSync(profileDir, { recursive: true, force: true });
};
// Signal handlers go in before anything waits, so a Ctrl-C during Chrome's
// startup still takes Chrome and the profile dir with it.
process.on("SIGINT", () => {
kill();
process.exit(130);
});
process.on("SIGTERM", () => {
kill();
process.exit(143);
});
// A Chrome that dies on its own fails the run now, not at a poll deadline.
// Our own SIGKILL in kill() fires this too; `killed` tells the two apart.
chrome.on("exit", (code, signal) => {
if (killed) return;
console.error(`chrome exited unexpectedly (code ${code}, signal ${signal})`);
kill();
process.exit(1);
});
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, kill);
} catch (err) {
kill();
throw err;
}
}
// Close the socket and tear down the server, Chrome, and profile dir.
stop(): void {
try {
this.cdp.close();
} catch {}
this.kill();
}
}
packages/e2e/harness.ts · 6.4 KiB · dc38216d2f