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",