yukey

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 · bdc1230fa7