docs/advanced/e2e
Capabilities · Real-window testing

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.

10 min read · includes recordings

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.

HARNESS PATH
Requests and responses are written to a temp file and renamed, so the other side never sees half-written JSON; the virtual cursor is painted into the render target at the very end and never enters the UI tree.

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.

e2e/profile.test.ts
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);
The pattern above, running for real: I-beam focus, IME preedit / commit, switching to the RTL story and clicking a Checkbox, all driven by the harness.

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.

01
Real shapes

I-beam over inputs, hand over buttons, grab turning into grabbing on drags; pointer capture and cursor overrides apply.

02
Readable actions

A blue press ring while the button is held; an atomic click leaves a 180 ms pulse after release.

03
Clean evidence

Retina app content plus the virtual cursor. No desktop, physical cursor, title bar or screen-sharing badge.

A Zenit Storybook checkbox turning checked after the harness's virtual hand cursor clicks it
The test finds the checkbox by test_id, and a real hit-test flips its Signal; the pixels and the read-back state come from the same run.

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.

e2e/gestures.test.ts
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);
The Drag story: mouseDown, then stepped mouseMove, then mouseUp; the box follows once past the 4 px threshold.
The Gestures story: two clickAt calls 160 ms apart, recognised by the gesture arena as a double click.

API map

GoalHarness API
Semantic lookuptree · query(testId) · screenPos · focused
Pointer and keyboardclickTestId · clickAt · mouseDown/Move/Up · scrollAt · magnifyAt · key
File drag-and-dropdragAt(x, y, kind, paths)
Text and IMEtype_ · imePreedit · imeCommit · inputState
ObservabilityconsoleEvents · waitForConsole · clearConsole · stats · resetTiming
Visual evidencescreenshot · startWindowRecording · windowRecordingStatus · stopWindowRecording
Waiting and windowwaitForServerReady · waitFor · resizeWindow

tree 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.

ConstraintDetail
Output pathMust be an absolute .mp4 path whose parent directory already exists
fps1–120, default 60
Window sizeNo resizing while recording; one H.264 track can’t change pixel size mid-stream
Resultok · width · height · frame_count · dropped_frames · duration_ms · file_size

Build 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.

Terminal · your app
# 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"
Terminal · zenit repo
# 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.ts

If 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.

  1. 1
    Build the targets in test mode

    Run zig build -Dtest-mode=true storybook console-probe devtools-probe hello-button counter-reactive in the zenit checkout.

  2. 2
    A private RPC dir per scenario

    Create a directory with mktemp -d, start the binary inside the .app in the background with ZENIT_E2E_FILE_RPC_DIR, run bun scripts/zenit/capture.ts <scenario> against the same directory, then kill the app.

  3. 3
    Drive semantically, record

    capture.ts imports zenit’s e2e/client.ts directly, locates by test_id or tree text, records at 30 fps and asserts ok plus frame_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.

  4. 4
    Crop, 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 to public/media/REVISION.

Terminal · this site
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
zenit · Dual-licensedFree for open-source projects under GPL-3.0-only; closed-source or commercial products need a commercial license.Contact the author: zongyi.xzy#gmail.com (replace # with @)zenit 4c50245 2026-10-01