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); +});