yukey

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: 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. 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/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 ยท f48fd5bcf8