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 { 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.  Paths are normalized and
// pinned under STATIC_DIR; anything else is a 404 (a test server, not a product).
function serveStatic(): Promise<{ baseUrl: string; server: Server }> {
	const srv = createServer((req, res) => {
		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;
		}
		try {
			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.0 KiB · 50823648d1