The E2E harness
Drive a real Zenit window by semantic locators, and turn the virtual cursor, PNG screenshots and H.264 recordings into reviewable evidence.
Test the real window, not a web stand-in
The harness exists only in apps built with -Dtest-mode=true. A TypeScript controller writes requests into a private file-RPC directory, and the app’s main thread drains the command queue after platform events: clicks still go through real hit-testing, input still goes through component events, and screenshots read the drawable that was actually presented.
Writing a first real-window test
Locate by test_id rather than hard-coded pixels. When you want a visible cursor path, read the node’s current rect with screenPos and send a granular mouse sequence.
import { mkdir } from "node:fs/promises";
import {
waitForServerReady, screenPos, mouseMove, clickAt,
imePreedit, imeCommit, inputState, screenshot,
startWindowRecording, stopWindowRecording,
} from "../zig-out/share/zenit/harness/client.ts";
const out = "/tmp/myapp-e2e-artifacts"; // absolute; must exist before recording
await mkdir(out, { recursive: true });
await waitForServerReady();
await startWindowRecording(`${out}/ime.mp4`, { fps: 30 });
// Locate by test_id, never by hard-coded pixels.
const field = await screenPos("profile.name");
const x = field.x + field.w / 2;
const y = field.y + field.h / 2;
await mouseMove(x, y);
await clickAt(x, y);
await imePreedit("nihongo", 7); // cursor offset in UTF-8 bytes
await screenshot(`${out}/preedit.png`);
await imeCommit("日本語");
const state = await inputState("profile.name");
if (state.buffer !== "日本語" || state.ime_preedit_len !== 0) {
throw new Error(JSON.stringify(state));
}
const video = await stopWindowRecording(); // returns once the MP4 is finalized
if (!video.ok || video.dropped_frames !== 0) throw new Error(video.error);Why the virtual cursor matters
System screenshots usually miss the physical cursor, and screen recording drags in the desktop, title bar and permission prompts. Zenit’s virtual cursor is a final draw-only overlay: it creates no Node and takes no part in layout or hit-testing. It only paints the current automation position and the resolved CursorShape into the app’s render target.
I-beam over inputs, hand over buttons, grab turning into grabbing on drags; pointer capture and cursor overrides apply.
A blue press ring while the button is held; an atomic click leaves a 180 ms pulse after release.
Retina app content plus the virtual cursor. No desktop, physical cursor, title bar or screen-sharing badge.
Drags and multi-clicks
Pointer drags are mouseDown / mouseMove / mouseUp sequences and go through the same drag threshold, pointer capture and gesture arena as a physical mouse. Double and triple clicks are consecutive clickAt calls within the multi-click interval.
import { mouseDown, mouseMove, mouseUp, screenPos, sleep, clickAt } from "../zig-out/share/zenit/harness/client.ts";
// Pointer drag: press, move in steps, release. Real hit-testing and
// pointer capture apply; the virtual cursor shows grab -> grabbing.
async function drag(from: { x: number; y: number }, to: { x: number; y: number }, steps = 10) {
await mouseDown(from.x, from.y);
for (let i = 1; i <= steps; i++) {
const k = i / steps;
await mouseMove(from.x + (to.x - from.x) * k, from.y + (to.y - from.y) * k);
await sleep(35);
}
await mouseUp(to.x, to.y);
}
const box = await screenPos("story.drag.box");
const c = { x: box.x + box.w / 2, y: box.y + box.h / 2 };
await drag(c, { x: c.x + 140, y: c.y + 40 });
// Double click: two clicks inside the platform's multi-click interval.
const pad = await screenPos("story.multiclick.pad");
await clickAt(pad.x + pad.w / 2, pad.y + pad.h / 2);
await sleep(160);
await clickAt(pad.x + pad.w / 2, pad.y + pad.h / 2);API map
tree · query(testId) · screenPos · focusedclickTestId · clickAt · mouseDown/Move/Up · scrollAt · magnifyAt · keydragAt(x, y, kind, paths)type_ · imePreedit · imeCommit · inputStateconsoleEvents · waitForConsole · clearConsole · stats · resetTimingscreenshot · startWindowRecording · windowRecordingStatus · stopWindowRecordingwaitForServerReady · waitFor · resizeWindowtree returns every child (the old 50-children-per-level cap is gone; depth is still capped at 20), and a result that overflows the 256 KB result buffer is an explicit error rather than half a JSON document. Each node matched by query carries effective_opacity (its own opacity times every ancestor’s, 0 under display:none), and display:none nodes carry hidden: true. To wait for an overlay to fade in, poll until effective_opacity >= 0.99 before clicking instead of sleeping for a guessed duration. The typed client’s QueryNode interface doesn’t declare these two fields yet, so extend the type when you read them.
Recording the drawable
On macOS, the recorder blits each finished Metal drawable on the GPU into an AVAssetWriterInputPixelBufferAdaptor buffer pool, and AVFoundation encodes H.264. There is no CPU pixel readback and no Screen Recording permission. stopWindowRecording is a synchronous boundary: when it returns, the MP4 index and the last frame are written.
fps1–120, default 60ok · width · height · frame_count · dropped_frames · duration_ms · file_sizeBuild and run
Zig dependency options are isolated, so a downstream build.zig must forward test-mode and e2e-port to the zenit dependency and call zenit.installHarnessClient(b, zenit_dep), which installs the typed client at zig-out/share/zenit/harness/client.ts. The template in Quick start already does both.
# The template's build.zig forwards -Dtest-mode / -De2e-port to the zenit
# dependency and calls zenit.installHarnessClient(b, zenit_dep).
zig build -Dtest-mode=true
rpc_dir="$(mktemp -d /tmp/myapp-e2e.XXXXXX)" # one private dir per run
ZENIT_E2E_FILE_RPC_DIR="$rpc_dir" ./zig-out/bin/myapp &
app_pid=$!
ZENIT_E2E_FILE_RPC_DIR="$rpc_dir" bun e2e/profile.test.ts
kill "$app_pid"# Inside the zenit repo: the Storybook ships as an .app bundle.
zig build -Dtest-mode=true storybook
rpc_dir="$(mktemp -d /tmp/zenit-e2e.XXXXXX)"
ZENIT_E2E_ISOLATE_POINTER=1 ZENIT_E2E_FILE_RPC_DIR="$rpc_dir" \
"./zig-out/zenit Storybook.app/Contents/MacOS/storybook" &
ZENIT_E2E_FILE_RPC_DIR="$rpc_dir" bun e2e/storybook.test.tsIf you use the machine while tests run, the physical mouse’s moves, buttons and wheel override the hover position the harness injected. Set ZENIT_E2E_ISOLATE_POINTER=1 on the app process and a test-mode build drops system mouse move / button / wheel / magnify events, accepting only harness input; zenit’s own scripts/run_storybook_e2e.sh turns it on by default. Set it to 0 when a real mouse should take part.
Normal release builds omit -Dtest-mode=true: test_harness.enabled is a compile-time constant, so harness setup and the file-RPC endpoint are compiled out entirely.
Real example: this site’s media pipeline
Every screenshot and clip on this site comes from a real window, produced by two scripts: scripts/zenit/run-capture.sh builds and orchestrates processes, and scripts/zenit/capture.ts drives and records.
- 1Build the targets in test mode
Run
zig build -Dtest-mode=true storybook console-probe devtools-probe hello-button counter-reactivein the zenit checkout. - 2A private RPC dir per scenario
Create a directory with
mktemp -d, start the binary inside the .app in the background withZENIT_E2E_FILE_RPC_DIR, runbun scripts/zenit/capture.ts <scenario>against the same directory, then kill the app. - 3Drive semantically, record
capture.ts imports zenit’s
e2e/client.tsdirectly, locates by test_id or tree text, records at 30 fps and assertsokplusframe_count ≥ 2; if a story’s interaction fails it falls back to a generic cursor sweep and logs a warning instead of aborting the batch. - 4Crop, transcode, stamp the revision
ffmpeg crops to the Storybook detail pane and re-encodes H.264 (faststart) into
public/media; finally the zenit commit is written topublic/media/REVISION.
ZENIT_DIR=/path/to/zenit scripts/zenit/run-capture.sh docs # docs media
ZENIT_DIR=/path/to/zenit scripts/zenit/run-capture.sh stories drag # one story clip