README.md
yukey-testkit๐
A dependency-free CDP client for driving a page with trusted input, and reading its state between steps.
It exists because synthetic DOM events can't drive real focus traversal:
a KeyboardEvent dispatched from JavaScript won't move focus on Tab,
won't type into an input, and won't trigger hover.
Trusted input (Input.dispatchKeyEvent and friends over the Chrome DevTools Protocol)
is what a real keyboard and mouse produce, so a test built on it exercises the same paths a user does.
That's the only kind of test that can prove a keyboard-first UI actually works from the keyboard.
Node 22+, using its built-in WebSocket. Nothing else.
What's in it๐
yukey-testkit/cdp: theCdpclass.Cdp.connect(wsUrl)to a page target; thenkey,click,mouseMove,wheelfor trusted input,js(expr)for state reads,screenshot(), andcmdfor any other protocol method. Key names are DOMKeyboardEvent.keystrings ("Tab","ArrowDown"," ","a"); an unknown name is an error, not typed text.jsrejects if the expression throws in the page, with the exception text.yukey-testkit/checks: theCheckscollector (a PASS/FAIL line protocol with counts), theScenarioandScenarioContextshapes a suite runner can share, andshotfor optional screenshot evidence (written only whenYUKEY_E2E_SHOTS_DIRis set).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.
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.
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๐
SPDX-License-Identifier: Apache-2.0 OR MIT
packages/yukey-testkit/README.md ยท 3.4 KiB ยท 39823989c8