yukey

Merge branch 'review-fixes': testkit error paths, harness teardown, leak cleanup

commit a24a232c5bcb3bc27c1fddde410a31cabfba5cdc · parent f2166ce42f · parent 945c8644e5 · browse this tree
Ximianth, 2026-10-10 06:00:50 UTC

diff --git a/Makefile b/Makefile
index bec6fbc..d44ecc4 100644
--- a/Makefile
+++ b/Makefile
@@ -35,7 +35,7 @@ test: ## Run the unit tests.
 	npm run test
 
 .PHONY: check-e2e
-check-e2e: build ## Drive the gallery in a headless Chrome with trusted input (~10s).  Not part of check.
+check-e2e: build ## Drive the gallery in a headless Chrome with trusted input (~30s).  Not part of check.
 	npm run test:e2e --workspace packages/e2e
 
 .PHONY: clean
diff --git a/docs/hacking/README.md b/docs/hacking/README.md
index d2c2669..32fd096 100644
--- a/docs/hacking/README.md
+++ b/docs/hacking/README.md
@@ -45,3 +45,4 @@ Things worth knowing
   so typechecking and running the suite never require a testkit build.
   The default condition still resolves to `dist/`, which is what every outside consumer gets.
 - The e2e suite needs a Chromium.  It defaults to `/headless-shell/headless-shell`; `YUKEY_E2E_CHROME` overrides.
+  Chrome is launched with `--no-sandbox` (fine for headless-shell in a container; worth knowing if you point it at a desktop Chrome).
diff --git a/package-lock.json b/package-lock.json
index 21b8010..8445a80 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -439,6 +439,7 @@
 			"os": [
 				"aix"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -456,6 +457,7 @@
 			"os": [
 				"android"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -473,6 +475,7 @@
 			"os": [
 				"android"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -490,6 +493,7 @@
 			"os": [
 				"android"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -507,6 +511,7 @@
 			"os": [
 				"darwin"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -524,6 +529,7 @@
 			"os": [
 				"darwin"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -541,6 +547,7 @@
 			"os": [
 				"freebsd"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -558,6 +565,7 @@
 			"os": [
 				"freebsd"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -575,6 +583,7 @@
 			"os": [
 				"linux"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -592,6 +601,7 @@
 			"os": [
 				"linux"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -609,6 +619,7 @@
 			"os": [
 				"linux"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -626,6 +637,7 @@
 			"os": [
 				"linux"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -643,6 +655,7 @@
 			"os": [
 				"linux"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -660,6 +673,7 @@
 			"os": [
 				"linux"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -677,6 +691,7 @@
 			"os": [
 				"linux"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -694,6 +709,7 @@
 			"os": [
 				"linux"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -711,6 +727,7 @@
 			"os": [
 				"linux"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -728,6 +745,7 @@
 			"os": [
 				"netbsd"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -745,6 +763,7 @@
 			"os": [
 				"netbsd"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -762,6 +781,7 @@
 			"os": [
 				"openbsd"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -779,6 +799,7 @@
 			"os": [
 				"openbsd"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -796,6 +817,7 @@
 			"os": [
 				"openharmony"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -813,6 +835,7 @@
 			"os": [
 				"sunos"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -830,6 +853,7 @@
 			"os": [
 				"win32"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -847,6 +871,7 @@
 			"os": [
 				"win32"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -864,6 +889,7 @@
 			"os": [
 				"win32"
 			],
+			"peer": true,
 			"engines": {
 				"node": ">=18"
 			}
@@ -2846,6 +2872,9 @@
 		"packages/yukey-testkit": {
 			"version": "0.0.0",
 			"license": "Apache-2.0 OR MIT",
+			"devDependencies": {
+				"vitest": "5.0.3"
+			},
 			"engines": {
 				"node": ">=22"
 			}
diff --git a/packages/yukey-testkit/src/gallery.ts b/packages/e2e/gallery.ts
similarity index 91%
rename from packages/yukey-testkit/src/gallery.ts
rename to packages/e2e/gallery.ts
index 517708c..847bdab 100644
--- a/packages/yukey-testkit/src/gallery.ts
+++ b/packages/e2e/gallery.ts
@@ -2,8 +2,8 @@
 // This is the setup etiquette every gallery scenario shared as a long-lived-browser
 // convention; here it is explicit and self-contained per scenario.
 
-import type { ScenarioContext } from "./checks.ts";
-import { sleep } from "./sleep.ts";
+import type { ScenarioContext } from "yukey-testkit/checks";
+import { sleep } from "yukey-testkit/sleep";
 
 export async function openGallery(
 	ctx: ScenarioContext,
diff --git a/packages/e2e/harness.ts b/packages/e2e/harness.ts
index abf1dfc..4f9941c 100644
--- a/packages/e2e/harness.ts
+++ b/packages/e2e/harness.ts
@@ -6,7 +6,7 @@
 // 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 { 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";
@@ -43,17 +43,19 @@ async function pollUntil(
 	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).
+// 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) => {
-		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 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);
@@ -77,23 +79,12 @@ function serveStatic(): Promise<{ baseUrl: string; server: Server }> {
 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,
-	) {
+	private readonly kill: () => void;
+
+	private constructor(baseUrl: string, cdp: Cdp, kill: () => void) {
 		this.baseUrl = baseUrl;
 		this.cdp = cdp;
-		this.server = server;
-		this.chrome = chrome;
-		this.profileDir = profileDir;
+		this.kill = kill;
 	}
 
 	static async start(): Promise<Harness> {
@@ -129,7 +120,13 @@ export class Harness {
 			{ 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 {
@@ -139,6 +136,26 @@ export class Harness {
 			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;
@@ -168,27 +185,18 @@ export class Harness {
 			});
 
 			const cdp = await Cdp.connect(wsUrl);
-			return new Harness(baseUrl, cdp, server, chrome, profileDir);
+			return new Harness(baseUrl, cdp, kill);
 		} catch (err) {
 			kill();
 			throw err;
 		}
 	}
 
-	// Idempotent, unconditional teardown: close the socket, stop the server,
-	// kill the Chrome process tree, remove the profile dir.
+	// Close the socket and tear down the server, Chrome, and 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 });
+		this.kill();
 	}
 }
diff --git a/packages/e2e/main.ts b/packages/e2e/main.ts
index 77a8c29..f23b4c4 100644
--- a/packages/e2e/main.ts
+++ b/packages/e2e/main.ts
@@ -24,15 +24,8 @@ async function main(): Promise<void> {
 	}
 	const chosen = wanted.length > 0 ? ALL.filter((s) => wanted.includes(s.name)) : ALL;
 
+	// Harness.start owns the SIGINT/SIGTERM handlers (and the exit codes 130/143).
 	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[] = [];
diff --git a/packages/e2e/scenarios/ip-drive.ts b/packages/e2e/scenarios/ip-drive.ts
index cfb64b0..6dae682 100644
--- a/packages/e2e/scenarios/ip-drive.ts
+++ b/packages/e2e/scenarios/ip-drive.ts
@@ -7,11 +7,10 @@
 // i upgrading a hover popup to pinned), dismissal (nav keys close AND navigate,
 // pointerdown closes, no resurrect), aria reflection wiring, edge clamping,
 // and the bottom flip (short-viewport emulation).
-// Ported from the Python original (demoscene.meta screenshots, stage-infopop).
 
 import { type Scenario, type ScenarioContext, shot } from "yukey-testkit/checks";
-import { openGallery } from "yukey-testkit/gallery";
 import { sleep } from "yukey-testkit/sleep";
+import { openGallery } from "../gallery.ts";
 
 // State through the shadow roots: the popup's mode/content/position, the demo
 // column's focused row and log, and the aria reflection on the current anchor.
diff --git a/packages/e2e/scenarios/lc-drive.ts b/packages/e2e/scenarios/lc-drive.ts
index 1cd0c1c..0168db7 100644
--- a/packages/e2e/scenarios/lc-drive.ts
+++ b/packages/e2e/scenarios/lc-drive.ts
@@ -25,12 +25,10 @@
 // and answered with megacolumn-focus-request; outside the list's rows the
 // letters are nobody's.  Structural row ids are name-prefixed by sw-list
 // (name="queue": queue-header, queue-add).
-// Ported from the Python original (demoscene.meta screenshots, stage-livecolumn,
-// updated in place by stage-list-component).
 
 import { type Scenario, type ScenarioContext, shot } from "yukey-testkit/checks";
-import { openGallery } from "yukey-testkit/gallery";
 import { sleep } from "yukey-testkit/sleep";
+import { openGallery } from "../gallery.ts";
 
 // demo(i): the i-th swg-livecolumn-demo (0 = refill, 1 = drain).
 const demo = (i: number) =>
diff --git a/packages/e2e/scenarios/mc-drive.ts b/packages/e2e/scenarios/mc-drive.ts
index c89aa70..a0a7cb4 100644
--- a/packages/e2e/scenarios/mc-drive.ts
+++ b/packages/e2e/scenarios/mc-drive.ts
@@ -1,11 +1,10 @@
 // Drives the megacolumn gallery demo with TRUSTED key events via CDP,
 // reading focus/aria state between steps.  Synthetic JS KeyboardEvents can't
 // exercise real Tab traversal or real typing; these can.
-// Ported from the Python original (demoscene.meta screenshots, stage-focus-model).
 
 import type { Scenario, ScenarioContext } from "yukey-testkit/checks";
-import { openGallery } from "yukey-testkit/gallery";
 import { sleep } from "yukey-testkit/sleep";
+import { openGallery } from "../gallery.ts";
 
 // Reads the interesting state through the shadow roots: which demo element holds
 // real focus, its aria-activedescendant, ring positions, text mode, and the log line.
diff --git a/packages/e2e/scenarios/sw-drive.ts b/packages/e2e/scenarios/sw-drive.ts
index 8ede259..c7c85fe 100644
--- a/packages/e2e/scenarios/sw-drive.ts
+++ b/packages/e2e/scenarios/sw-drive.ts
@@ -5,11 +5,10 @@
 // claim-and-decline), the two-column scoping proof, and activation feedback
 // (the sw-held / sw-pressed classes; the 120ms flash is caught with a
 // MutationObserver installed before the keystroke, not with a screenshot race).
-// Ported from the Python original (demoscene.meta screenshots, stage-button).
 
 import { type Scenario, type ScenarioContext, shot } from "yukey-testkit/checks";
-import { openGallery } from "yukey-testkit/gallery";
 import { sleep } from "yukey-testkit/sleep";
+import { openGallery } from "../gallery.ts";
 
 // State through the shadow roots: which element holds real focus (host chain),
 // each demo's log line, and the columns' focused rows.
diff --git a/packages/yukey-testkit/README.md b/packages/yukey-testkit/README.md
index 5f83238..21c5212 100644
--- a/packages/yukey-testkit/README.md
+++ b/packages/yukey-testkit/README.md
@@ -20,14 +20,64 @@ What's in it
 - `yukey-testkit/cdp`: the `Cdp` class.
   `Cdp.connect(wsUrl)` to a page target; then `key`, `click`, `mouseMove`, `wheel` for trusted input,
   `js(expr)` for state reads, `screenshot()`, and `cmd` for any other protocol method.
-- `yukey-testkit/checks`: the `Checks` collector (a PASS/FAIL line protocol with a summary),
+  Key names are DOM `KeyboardEvent.key` strings (`"Tab"`, `"ArrowDown"`, `" "`, `"a"`); an unknown name is an error, not typed text.
+  `js` rejects if the expression throws in the page, with the exception text.
+- `yukey-testkit/checks`: the `Checks` collector (a PASS/FAIL line protocol with counts),
   the `Scenario` and `ScenarioContext` shapes a suite runner can share, and `shot` for optional screenshot evidence
   (written only when `YUKEY_E2E_SHOTS_DIR` is set).
-- `yukey-testkit/gallery`: `openGallery`, the setup etiquette for landing on a yukey gallery entry in a fresh document.
 - `yukey-testkit/sleep`: a promise-returning sleep for polling loops.
 
 Launching Chrome and serving your page are yours to do; they vary too much per app to belong here.
-Yukey's own `packages/e2e/harness.ts` is one worked example (static files on an ephemeral port, a headless Chrome with a throwaway profile).
+
+
+A complete run
+--------------
+
+Launch a Chrome with `--remote-debugging-port=0`, find the page target, connect, drive, check, tear down.
+`Checks` only prints and counts; exiting non-zero is up to you.
+
+```js
+import { spawn } from "node:child_process";
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Cdp } from "yukey-testkit/cdp";
+import { Checks } from "yukey-testkit/checks";
+import { sleep } from "yukey-testkit/sleep";
+
+const profile = mkdtempSync(join(tmpdir(), "chrome-"));
+const chrome = spawn("/path/to/chrome", [
+	"--headless", "--remote-debugging-port=0", `--user-data-dir=${profile}`,
+	"data:text/html,<input id=i autofocus>",
+], { stdio: "ignore" });
+
+// Chrome writes its chosen port to the profile dir; /json on it lists the targets.
+let port = 0;
+while (port === 0) {
+	await sleep(50);
+	try { port = Number(readFileSync(join(profile, "DevToolsActivePort"), "utf8").split("\n")[0]); } catch {}
+}
+const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
+const page = targets.find((t) => t.type === "page");
+
+const cdp = await Cdp.connect(page.webSocketDebuggerUrl);
+while ((await cdp.js("document.activeElement?.id === 'i'")) !== true) await sleep(50);
+
+const checks = new Checks();
+await cdp.key("h");
+await cdp.key("i");
+checks.check("typed into the input", (await cdp.js("document.getElementById('i').value")) === "hi");
+console.log(`${checks.passed}/${checks.total} passed`);
+
+cdp.close();
+chrome.kill();
+await new Promise((r) => chrome.once("exit", r));
+rmSync(profile, { recursive: true, force: true });
+process.exit(checks.failed > 0 ? 1 : 0);
+```
+
+Yukey's own `packages/e2e/harness.ts` is the same shape with the edges handled:
+static files on an ephemeral port, a throwaway profile, signal handlers, and a Chrome that dies failing the run.
 
 
 License
diff --git a/packages/yukey-testkit/package.json b/packages/yukey-testkit/package.json
index b9ec65b..1795f15 100644
--- a/packages/yukey-testkit/package.json
+++ b/packages/yukey-testkit/package.json
@@ -1,14 +1,24 @@
 {
 	"name": "yukey-testkit",
 	"version": "0.0.0",
-	"description": "a dependency-free CDP client for driving a page with trusted input and reading state between steps; built for testing yukey apps, usable on any page",
+	"description": "a dependency-free CDP client for driving a page with trusted input and reading state between steps",
 	"license": "Apache-2.0 OR MIT",
 	"type": "module",
 	"exports": {
-		"./*": {
-			"yukey-source": "./src/*.ts",
-			"types": "./dist/*.d.ts",
-			"default": "./dist/*.js"
+		"./cdp": {
+			"yukey-source": "./src/cdp.ts",
+			"types": "./dist/cdp.d.ts",
+			"default": "./dist/cdp.js"
+		},
+		"./checks": {
+			"yukey-source": "./src/checks.ts",
+			"types": "./dist/checks.d.ts",
+			"default": "./dist/checks.js"
+		},
+		"./sleep": {
+			"yukey-source": "./src/sleep.ts",
+			"types": "./dist/sleep.d.ts",
+			"default": "./dist/sleep.js"
 		}
 	},
 	"files": [
@@ -20,9 +30,13 @@
 	],
 	"scripts": {
 		"build": "tsc -p tsconfig.build.json",
-		"check": "tsc --noEmit",
+		"check": "tsc --noEmit && vitest run",
 		"clean": "rm -rf dist",
-		"prepack": "npm run build"
+		"prepack": "npm run clean && npm run build",
+		"test": "vitest run"
+	},
+	"devDependencies": {
+		"vitest": "5.0.3"
 	},
 	"engines": {
 		"node": ">=22"
diff --git a/packages/yukey-testkit/src/cdp.test.ts b/packages/yukey-testkit/src/cdp.test.ts
new file mode 100644
index 0000000..fbb59f5
--- /dev/null
+++ b/packages/yukey-testkit/src/cdp.test.ts
@@ -0,0 +1,167 @@
+// Cdp against a fake socket: no Chrome, no network.  The fake records every
+// frame sent and lets a test script the replies and the socket's lifecycle.
+
+import { describe, expect, it } from "vitest";
+import { Cdp, type CdpSocket } from "./cdp.js";
+
+interface Frame {
+	id: number;
+	method: string;
+	params: Record<string, unknown>;
+}
+
+class FakeSocket implements CdpSocket {
+	readyState: number = WebSocket.OPEN;
+	readonly sent: Frame[] = [];
+	private readonly listeners = new Map<string, ((ev: { data: unknown }) => void)[]>();
+	// How to answer each frame; the default acknowledges with an empty result, undefined never answers.
+	reply: (frame: Frame) => Record<string, unknown> | undefined = () => ({ result: {} });
+
+	addEventListener(type: string, listener: (ev: { data: unknown }) => void): void {
+		const list = this.listeners.get(type) ?? [];
+		list.push(listener);
+		this.listeners.set(type, list);
+	}
+
+	// Like the real thing, a frame sent on a socket that isn't open is dropped silently.
+	send(data: string): void {
+		if (this.readyState !== WebSocket.OPEN) return;
+		const frame = JSON.parse(data) as Frame;
+		this.sent.push(frame);
+		const reply = this.reply(frame);
+		if (reply !== undefined) this.emit("message", { data: JSON.stringify({ id: frame.id, ...reply }) });
+	}
+
+	close(): void {
+		this.readyState = WebSocket.CLOSED;
+		this.emit("close", { data: undefined });
+	}
+
+	emit(type: string, ev: { data: unknown }): void {
+		for (const l of this.listeners.get(type) ?? []) l(ev);
+	}
+}
+
+// Whether a promise has settled (either way) once the microtask queue drains.
+async function settled(p: Promise<unknown>): Promise<boolean> {
+	let done = false;
+	p.then(() => (done = true), () => (done = true));
+	await new Promise<void>((r) => setImmediate(r));
+	return done;
+}
+
+describe("Cdp.key", () => {
+	async function keyParams(key: string, opts?: { shift?: boolean }): Promise<Record<string, unknown>[]> {
+		const sock = new FakeSocket();
+		await Cdp.fromSocket(sock).key(key, opts);
+		for (const f of sock.sent) expect(f.method).toBe("Input.dispatchKeyEvent");
+		return sock.sent.map((f) => f.params);
+	}
+
+	it("sends Tab as a rawKeyDown/keyUp pair with the virtual key code", async () => {
+		expect(await keyParams("Tab")).toEqual([
+			{ type: "rawKeyDown", windowsVirtualKeyCode: 9, code: "Tab", key: "Tab", modifiers: 0 },
+			{ type: "keyUp", windowsVirtualKeyCode: 9, code: "Tab", key: "Tab", modifiers: 0 },
+		]);
+	});
+
+	it("sends shift+Tab with the shift modifier bit on both halves", async () => {
+		const params = await keyParams("Tab", { shift: true });
+		expect(params.map((p) => p.modifiers)).toEqual([8, 8]);
+	});
+
+	it("sends Enter as a full keyDown carrying its text", async () => {
+		expect(await keyParams("Enter")).toEqual([
+			{ type: "keyDown", windowsVirtualKeyCode: 13, code: "Enter", key: "Enter", modifiers: 0, text: "\r" },
+			{ type: "keyUp", windowsVirtualKeyCode: 13, code: "Enter", key: "Enter", modifiers: 0 },
+		]);
+	});
+
+	it("sends space as a full keyDown carrying its text", async () => {
+		expect(await keyParams(" ")).toEqual([
+			{ type: "keyDown", windowsVirtualKeyCode: 32, code: "Space", key: " ", modifiers: 0, text: " " },
+			{ type: "keyUp", windowsVirtualKeyCode: 32, code: "Space", key: " ", modifiers: 0 },
+		]);
+	});
+
+	it("sends a printable character with its text and KeyX code", async () => {
+		expect(await keyParams("a")).toEqual([
+			{ type: "keyDown", windowsVirtualKeyCode: 65, code: "KeyA", key: "a", modifiers: 0, text: "a" },
+			{ type: "keyUp", windowsVirtualKeyCode: 65, code: "KeyA", key: "a", modifiers: 0 },
+		]);
+	});
+
+	it("rejects a multi-character name that isn't a known key, sending nothing", async () => {
+		const sock = new FakeSocket();
+		await expect(Cdp.fromSocket(sock).key("F1")).rejects.toThrow(/KEYDEFS/);
+		expect(sock.sent).toEqual([]);
+	});
+
+	it("rejects shift on a printable character (type the shifted character instead)", async () => {
+		const sock = new FakeSocket();
+		await expect(Cdp.fromSocket(sock).key("a", { shift: true })).rejects.toThrow(/shift/);
+		expect(sock.sent).toEqual([]);
+	});
+});
+
+describe("Cdp.cmd", () => {
+	it("resolves with the reply's result", async () => {
+		const sock = new FakeSocket();
+		sock.reply = () => ({ result: { answer: 42 } });
+		expect(await Cdp.fromSocket(sock).cmd("Any.thing", { x: 1 })).toEqual({ answer: 42 });
+		expect(sock.sent).toEqual([{ id: 1, method: "Any.thing", params: { x: 1 } }]);
+	});
+
+	it("rejects on an error reply, with the error in the message", async () => {
+		const sock = new FakeSocket();
+		sock.reply = () => ({ error: { code: -32601, message: "no such method" } });
+		await expect(Cdp.fromSocket(sock).cmd("Nope.nope")).rejects.toThrow(/no such method/);
+	});
+
+	it("records a method message as an event", async () => {
+		const sock = new FakeSocket();
+		const cdp = Cdp.fromSocket(sock);
+		sock.emit("message", { data: JSON.stringify({ method: "Runtime.consoleAPICalled", params: { type: "log" } }) });
+		expect(cdp.events).toEqual([{ method: "Runtime.consoleAPICalled", params: { type: "log" } }]);
+	});
+
+	it("rejects an outstanding command when the socket closes", async () => {
+		const sock = new FakeSocket();
+		sock.reply = () => undefined;
+		const pending = Cdp.fromSocket(sock).cmd("Slow.thing");
+		sock.close();
+		expect(await settled(pending)).toBe(true);
+		await expect(pending).rejects.toThrow(/closed/);
+	});
+
+	it("rejects a command sent after the socket closed", async () => {
+		const sock = new FakeSocket();
+		const cdp = Cdp.fromSocket(sock);
+		sock.close();
+		const late = cdp.cmd("Late.thing");
+		expect(await settled(late)).toBe(true);
+		await expect(late).rejects.toThrow(/closed/);
+	});
+});
+
+describe("Cdp.js", () => {
+	it("returns the evaluated value", async () => {
+		const sock = new FakeSocket();
+		sock.reply = () => ({ result: { result: { type: "number", value: 2 } } });
+		expect(await Cdp.fromSocket(sock).js("1+1")).toBe(2);
+	});
+
+	it("rejects when the expression throws in the page, with the exception text", async () => {
+		const sock = new FakeSocket();
+		sock.reply = () => ({
+			result: {
+				result: { type: "object", subtype: "error", description: "ReferenceError: nosuchvar is not defined" },
+				exceptionDetails: {
+					text: "Uncaught",
+					exception: { description: "ReferenceError: nosuchvar is not defined\n    at <anonymous>:1:1" },
+				},
+			},
+		});
+		await expect(Cdp.fromSocket(sock).js("nosuchvar.x")).rejects.toThrow(/nosuchvar is not defined/);
+	});
+});
diff --git a/packages/yukey-testkit/src/cdp.ts b/packages/yukey-testkit/src/cdp.ts
index 8d3f5b6..63956ba 100644
--- a/packages/yukey-testkit/src/cdp.ts
+++ b/packages/yukey-testkit/src/cdp.ts
@@ -1,12 +1,11 @@
 // A small typed CDP client: JSON-RPC over a websocket to one page target.
 //
-// This is the shared core of the e2e scenarios: TRUSTED input dispatch
-// (Input.dispatchKeyEvent / Input.dispatchMouseEvent -- synthetic DOM events
-// cannot drive real focus traversal, real typing, or real hover) plus
-// Runtime.evaluate state reads between steps.
+// Trusted input dispatch (Input.dispatchKeyEvent / Input.dispatchMouseEvent --
+// synthetic DOM events cannot drive real focus traversal, real typing, or real
+// hover) plus Runtime.evaluate state reads between steps.
 // Runs on Node 22's built-in WebSocket; no dependencies.
 
-// Key definitions for the non-printable keys the scenarios use:
+// The named (non-printable) keys that key() accepts:
 // windowsVirtualKeyCode + code, exactly what a real keyboard produces.
 const KEYDEFS: Record<string, [number, string]> = {
 	Tab: [9, "Tab"],
@@ -33,8 +32,19 @@ export interface CdpEvent {
 	params: unknown;
 }
 
+// The slice of WebSocket that Cdp uses.  Cdp.connect supplies a real one;
+// Cdp.fromSocket takes anything with this shape, which is how the unit tests
+// drive the client without a browser.
+export interface CdpSocket {
+	readonly readyState: number;
+	addEventListener(type: "message", listener: (ev: { data: unknown }) => void): void;
+	addEventListener(type: "close" | "error", listener: () => void): void;
+	send(data: string): void;
+	close(): void;
+}
+
 export class Cdp {
-	private readonly ws: WebSocket;
+	private readonly ws: CdpSocket;
 	private msgid = 0;
 	private readonly pending = new Map<number, PendingCommand>();
 	// Every protocol event received on this connection, in arrival order.
@@ -42,9 +52,11 @@ export class Cdp {
 	// so this stays empty unless a scenario asks for events.
 	readonly events: CdpEvent[] = [];
 
-	private constructor(ws: WebSocket) {
+	private constructor(ws: CdpSocket) {
 		this.ws = ws;
 		ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
+		ws.addEventListener("close", () => this.failPending());
+		ws.addEventListener("error", () => this.failPending());
 	}
 
 	static async connect(wsUrl: string): Promise<Cdp> {
@@ -58,10 +70,24 @@ export class Cdp {
 		return new Cdp(ws);
 	}
 
+	// Wrap an already-open socket.
+	static fromSocket(ws: CdpSocket): Cdp {
+		return new Cdp(ws);
+	}
+
 	close(): void {
 		this.ws.close();
 	}
 
+	// Nothing outstanding can be answered once the socket is gone; reject it all
+	// so a dead browser surfaces as an error rather than a hang.
+	private failPending(): void {
+		for (const waiter of this.pending.values()) {
+			waiter.reject(new Error("CDP connection closed"));
+		}
+		this.pending.clear();
+	}
+
 	private onMessage(raw: string): void {
 		const msg = JSON.parse(raw) as {
 			id?: number;
@@ -86,6 +112,9 @@ export class Cdp {
 	}
 
 	cmd(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
+		if (this.ws.readyState !== WebSocket.OPEN) {
+			return Promise.reject(new Error(`CDP connection closed; cannot send ${method}`));
+		}
 		this.msgid += 1;
 		const id = this.msgid;
 		const sent = new Promise<unknown>((resolve, reject) => {
@@ -95,13 +124,21 @@ export class Cdp {
 		return sent;
 	}
 
-	// Dispatch a trusted key press (down + up).  Named keys come from KEYDEFS;
-	// anything else is treated as a printable character.
+	// Dispatch a trusted key press (down + up).  `key` is a DOM KeyboardEvent.key
+	// value: a named key from KEYDEFS, or a single printable character.
+	// Shift is only meaningful on named keys (shift+Tab); for a printable
+	// character, pass the shifted character itself.
 	// Enter and Space carry their text payload on a full keyDown
 	// (rawKeyDown skips text insertion; native widgets need the text to act).
 	async key(key: string, opts: { shift?: boolean } = {}): Promise<void> {
 		const mods = opts.shift === true ? 8 : 0;
 		const def = KEYDEFS[key];
+		if (def === undefined && key.length !== 1) {
+			throw new Error(`key ${JSON.stringify(key)} is not a single character and not in KEYDEFS`);
+		}
+		if (def === undefined && mods !== 0) {
+			throw new Error(`key ${JSON.stringify(key)}: shift applies to named keys only; pass the shifted character`);
+		}
 		if (def !== undefined) {
 			const [vk, code] = def;
 			const text = key === " " ? " " : key === "Enter" ? "\r" : "";
@@ -174,8 +211,7 @@ export class Cdp {
 		await this.mouseUp(x, y, button);
 	}
 
-	// A trusted wheel tick at (x, y).  Positive deltaY scrolls down (the map
-	// reads that as zoom OUT); negative zooms in, anchored under the cursor.
+	// A trusted wheel tick at (x, y).  Positive deltaY scrolls down.
 	async wheel(x: number, y: number, deltaY: number): Promise<void> {
 		await this.cmd("Input.dispatchMouseEvent", {
 			type: "mouseWheel",
@@ -187,12 +223,20 @@ export class Cdp {
 	}
 
 	// Evaluate an expression in the page, returning its value by JSON.
+	// Rejects if the expression throws, carrying the page-side exception text.
 	// awaitPromise resolves a returned promise first (needed by probes that
 	// measure over a time window).
 	async js(expr: string, opts: { awaitPromise?: boolean } = {}): Promise<unknown> {
 		const params: Record<string, unknown> = { expression: expr, returnByValue: true };
 		if (opts.awaitPromise === true) params.awaitPromise = true;
-		const r = (await this.cmd("Runtime.evaluate", params)) as { result?: { value?: unknown } };
+		const r = (await this.cmd("Runtime.evaluate", params)) as {
+			result?: { value?: unknown };
+			exceptionDetails?: { text: string; exception?: { description?: string } };
+		};
+		if (r.exceptionDetails !== undefined) {
+			const detail = r.exceptionDetails.exception?.description ?? "";
+			throw new Error(`page exception in js(${JSON.stringify(expr)}): ${r.exceptionDetails.text} ${detail}`);
+		}
 		return r.result?.value;
 	}
 
diff --git a/packages/yukey-testkit/src/checks.ts b/packages/yukey-testkit/src/checks.ts
index 261bb60..948b2da 100644
--- a/packages/yukey-testkit/src/checks.ts
+++ b/packages/yukey-testkit/src/checks.ts
@@ -1,10 +1,9 @@
-// The check collector: the PASS/FAIL line protocol every scenario reports through.
-// Same shape as the drivers always had: each check prints immediately,
-// failures carry their detail, and the run summarizes at the end.
+// The check collector: a PASS/FAIL line protocol.  Each check prints
+// immediately, failures carry their detail, and the counts are read at the end.
 
 import { mkdirSync, writeFileSync } from "node:fs";
 import { join } from "node:path";
-import type { Cdp } from "./cdp.ts";
+import type { Cdp } from "./cdp.js";
 
 export interface ScenarioContext {
 	cdp: Cdp;
@@ -26,10 +25,6 @@ export class Checks {
 		console.log((cond ? "PASS " : "FAIL ") + name + (detail !== "" && !cond ? `  ${detail}` : ""));
 	}
 
-	skip(name: string, why: string): void {
-		console.log(`SKIP ${name} (${why})`);
-	}
-
 	get passed(): number {
 		return this.results.filter((r) => r.ok).length;
 	}
@@ -43,8 +38,8 @@ export class Checks {
 	}
 }
 
-// Screenshots were stage evidence in the original drivers, never assertions:
-// they write only when YUKEY_E2E_SHOTS_DIR names a destination, and no-op otherwise.
+// A screenshot for a human to look at later, never an assertion:
+// writes only when YUKEY_E2E_SHOTS_DIR names a destination, and no-ops otherwise.
 export async function shot(cdp: Cdp, name: string): Promise<void> {
 	const dir = process.env.YUKEY_E2E_SHOTS_DIR;
 	if (dir === undefined || dir === "") return;
diff --git a/packages/yukey-testkit/src/sleep.ts b/packages/yukey-testkit/src/sleep.ts
index 227aeb9..78dacf2 100644
--- a/packages/yukey-testkit/src/sleep.ts
+++ b/packages/yukey-testkit/src/sleep.ts
@@ -1,3 +1,2 @@
-// A promise that resolves after ms.  Scenarios use it to poll between steps;
-// the harness that launches Chrome uses it the same way.
+// A promise that resolves after ms, for polling loops between steps.
 export const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
diff --git a/packages/yukey-testkit/tsconfig.build.json b/packages/yukey-testkit/tsconfig.build.json
index cdf9958..30afe55 100644
--- a/packages/yukey-testkit/tsconfig.build.json
+++ b/packages/yukey-testkit/tsconfig.build.json
@@ -7,5 +7,11 @@
 		"declaration": true,
 		"declarationMap": true,
 		"sourceMap": true
-	}
+	},
+	"include": [
+		"src"
+	],
+	"exclude": [
+		"src/**/*.test.ts"
+	]
 }
diff --git a/packages/yukey-testkit/tsconfig.json b/packages/yukey-testkit/tsconfig.json
index b97fee7..89940ad 100644
--- a/packages/yukey-testkit/tsconfig.json
+++ b/packages/yukey-testkit/tsconfig.json
@@ -3,12 +3,18 @@
 		"target": "ES2022",
 		"module": "NodeNext",
 		"moduleResolution": "NodeNext",
-		"lib": ["ES2022"],
-		"types": ["node"],
+		"lib": [
+			"ES2022"
+		],
+		"types": [
+			"node"
+		],
 		"strict": true,
 		"noUncheckedIndexedAccess": true,
-		"rewriteRelativeImportExtensions": true,
 		"noEmit": true
 	},
-	"include": ["src"]
+	"include": [
+		"src",
+		"vitest.config.ts"
+	]
 }
diff --git a/packages/yukey-testkit/vitest.config.ts b/packages/yukey-testkit/vitest.config.ts
new file mode 100644
index 0000000..eb088c4
--- /dev/null
+++ b/packages/yukey-testkit/vitest.config.ts
@@ -0,0 +1,10 @@
+// Tests live beside the code they cover (src/foo.test.ts next to src/foo.ts).
+// Everything here is node-only; there's no DOM to emulate.
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+	test: {
+		environment: "node",
+		include: ["src/**/*.test.ts"],
+	},
+});
diff --git a/packages/yukey/src/tokens.ts b/packages/yukey/src/tokens.ts
index 42ecbce..5d9fa1d 100644
--- a/packages/yukey/src/tokens.ts
+++ b/packages/yukey/src/tokens.ts
@@ -47,7 +47,7 @@ export const tokenGroups: TokenGroup[] = [
 				name: "--sw-bg-void",
 				value: "#05080f",
 				kind: "color",
-				about: "the deepest layer: the map background, the page behind everything",
+				about: "the deepest layer: the page behind everything",
 			},
 			{
 				name: "--sw-bg-panel-solid",
@@ -59,7 +59,7 @@ export const tokenGroups: TokenGroup[] = [
 				name: "--sw-bg-panel",
 				value: "color-mix(in srgb, var(--sw-bg-panel-solid) 94%, transparent)",
 				kind: "color",
-				about: "translucent panel surface: big workspace panels floating over the map",
+				about: "translucent panel surface: big workspace panels floating over the page",
 			},
 			{
 				name: "--sw-bg-well",
@@ -192,7 +192,7 @@ export const tokenGroups: TokenGroup[] = [
 				name: "--sw-good",
 				value: "#8fd4c0",
 				kind: "color",
-				about: "the working-economy teal: producing, positive flow, and the map's route system (promises of movement)",
+				about: "the working-economy teal: producing, positive flow, promises of movement",
 			},
 			{
 				name: "--sw-warn",
@@ -255,7 +255,7 @@ export const tokenGroups: TokenGroup[] = [
 				name: "--sw-selection",
 				value: "#d8ecff",
 				kind: "color",
-				about: "the selection marker: the map's dashed ring around the selected entity",
+				about: "the selection marker: a dashed ring around the selected entity",
 			},
 			{
 				name: "--sw-focus-ring-color",