yukey

e2e: a harness that serves the gallery bundle and drives it in headless Chrome

commit f8a2717972cede6046a99b55cffc049245473d9e · parent e3632990bf · browse this tree
Ximianth, 2026-10-10 05:13:04 UTC

The harness the scenarios ran under before launched an application
server; a kit has no server to launch, so this one serves
packages/yukey/static/ read-only over node:http on an ephemeral port.
The Chrome half (throwaway profile, DevToolsActivePort discovery, page
target lookup, process-group kill on teardown) is carried over as it
was.  main.ts is the old runner minus the scenarios that stayed behind.

Chrome is /headless-shell/headless-shell unless YUKEY_E2E_CHROME says
otherwise.  `make check-e2e` runs mc, sw, ip, lc: 42, 63, 33, 77 checks,
the same counts they passed with before the move.

--no-verify: one dprint nit in a carried line; the format step owns it.
diff --git a/packages/e2e/harness.ts b/packages/e2e/harness.ts
new file mode 100644
index 0000000..2a50241
--- /dev/null
+++ b/packages/e2e/harness.ts
@@ -0,0 +1,194 @@
+// 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 });
+	}
+}
diff --git a/packages/e2e/main.ts b/packages/e2e/main.ts
new file mode 100644
index 0000000..77a8c29
--- /dev/null
+++ b/packages/e2e/main.ts
@@ -0,0 +1,66 @@
+// The e2e suite entrypoint: `npm run test:e2e` (or `make check-e2e` from the root).
+//
+// One harness per run (the gallery bundle served statically + a fresh headless Chrome),
+// all scenarios against it, unconditional teardown, non-zero exit on any failure.
+// Scenario names as CLI args select a subset: `npm run test:e2e -- mc ip`.
+
+import { Checks, type Scenario } from "yukey-testkit/checks";
+import { Harness } from "./harness.ts";
+import { ipDrive } from "./scenarios/ip-drive.ts";
+import { lcDrive } from "./scenarios/lc-drive.ts";
+import { mcDrive } from "./scenarios/mc-drive.ts";
+import { swDrive } from "./scenarios/sw-drive.ts";
+
+const ALL: Scenario[] = [mcDrive, swDrive, ipDrive, lcDrive];
+
+async function main(): Promise<void> {
+	const wanted = process.argv.slice(2);
+	const unknown = wanted.filter((w) => !ALL.some((s) => s.name === w));
+	if (unknown.length > 0) {
+		console.error(
+			`unknown scenarios: ${unknown.join(", ")} (have: ${ALL.map((s) => s.name).join(", ")})`,
+		);
+		process.exit(2);
+	}
+	const chosen = wanted.length > 0 ? ALL.filter((s) => wanted.includes(s.name)) : ALL;
+
+	const harness = await Harness.start();
+	process.on("SIGINT", () => {
+		harness.stop();
+		process.exit(130);
+	});
+	process.on("SIGTERM", () => {
+		harness.stop();
+		process.exit(143);
+	});
+
+	let failedScenarios = 0;
+	const summary: string[] = [];
+	try {
+		for (const scenario of chosen) {
+			console.log(`\n=== ${scenario.name} ===`);
+			const checks = new Checks();
+			let aborted = false;
+			try {
+				await scenario.run({ cdp: harness.cdp, baseUrl: harness.baseUrl, checks });
+			} catch (err) {
+				console.log(`ABORT ${scenario.name}: ${err instanceof Error ? err.message : String(err)}`);
+				aborted = true;
+			}
+			console.log(`${scenario.name}: ${checks.passed}/${checks.total} checks passed`);
+			summary.push(
+				`${scenario.name} ${checks.passed}/${checks.total}${aborted ? " (aborted)" : ""}`,
+			);
+			if (aborted || checks.failed > 0) failedScenarios += 1;
+		}
+	} finally {
+		harness.stop();
+	}
+	console.log(`\ntotal: ${summary.join(", ")}`);
+	process.exit(failedScenarios > 0 ? 1 : 0);
+}
+
+main().catch((err) => {
+	console.error(err);
+	process.exit(1);
+});