---
title: "E2E harness | zenit Zig UI Docs"
description: "Drive a real Zenit window by semantic locators, and turn the virtual cursor, PNG screenshots and H.264 recordings into reviewable evidence."
url: https://ui.surf/zenit/docs/advanced/e2e
language: en
alternate_zh: https://ui.surf/zenit/zh/docs/advanced/e2e.md
alternate_es: https://ui.surf/zenit/es/docs/advanced/e2e.md
alternate_ja: https://ui.surf/zenit/ja/docs/advanced/e2e.md
alternate_ko: https://ui.surf/zenit/ko/docs/advanced/e2e.md
alternate_fr: https://ui.surf/zenit/fr/docs/advanced/e2e.md
alternate_de: https://ui.surf/zenit/de/docs/advanced/e2e.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

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

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`

```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);
```

[Video](https://ui.surf/zenit/media/e2e-text-ime.mp4?v=efa93e6b3f)

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.

**Real shapes**

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

**Readable actions**

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

**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](https://ui.surf/zenit/media/e2e-virtual-cursor.png?v=ae67df2a38)](https://ui.surf/zenit/media/e2e-virtual-cursor.png?v=ae67df2a38)

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`

```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);
```

[Video](https://ui.surf/zenit/media/stories/drag.mp4?v=224e651da9)

The [Drag](https://ui.surf/zenit/components/drag) story: mouseDown, then stepped mouseMove, then mouseUp; the box follows once past the 4 px threshold.

[Video](https://ui.surf/zenit/media/stories/multiclick.mp4?v=e6fbe37258)

The [Gestures](https://ui.surf/zenit/components/multiclick) story: two clickAt calls 160 ms apart, recognised by the gesture arena as a double click.

> WARNING
> 
> **dragAt is not a pointer drag.** `dragAt(x, y, kind, paths)` simulates a **file** drag-and-drop session (as from Finder); `kind` 0–3 means entered / updated / exited / dropped. To move UI elements, use the mouse sequence above.

## API map

| Goal | Harness API |
| --- | --- |
| Semantic lookup | `tree` · `query(testId)` · `screenPos` · `focused` |
| Pointer and keyboard | `clickTestId` · `clickAt` · `mouseDown/Move/Up` · `scrollAt` · `magnifyAt` · `key` |
| File drag-and-drop | `dragAt(x, y, kind, paths)` |
| Text and IME | `type_` · `imePreedit` · `imeCommit` · `inputState` |
| Observability | `consoleEvents` · `waitForConsole` · `clearConsole` · `stats` · `resetTiming` |
| Visual evidence | `screenshot` · `startWindowRecording` · `windowRecordingStatus` · `stopWindowRecording` |
| Waiting and window | `waitForServerReady` · `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.

| Constraint | Detail |
| --- | --- |
| Output path | Must be an absolute .mp4 path whose parent directory already exists |
| `fps` | 1–120, default 60 |
| Window size | No resizing while recording; one H.264 track can’t change pixel size mid-stream |
| Result | `ok` · `width` · `height` · `frame_count` · `dropped_frames` · `duration_ms` · `file_size` |

> WARNING
> 
> **Stop before resizing.** A resize ends the recording with an error. Stop first, resize, then start a new file.

## 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](https://ui.surf/zenit/docs/guide/getting-started) already does both.

```sh
# 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"
```

```sh
# 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
```

> NOTE
> 
> **One RPC dir per run.** The app and the controller must see the same `ZENIT_E2E_FILE_RPC_DIR`; without it both fall back to `/tmp/zenit_e2e_rpc_19816` (the suffix is `-De2e-port`). The server writes `owner.json` as a single-owner lock: if another live instance already owns the directory, the new one refuses to serve rather than racing it for requests.

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.  **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.  **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.  **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.  **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`.
    

```sh
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
```

> TIP
> 
> **These docs are reproducible.** Re-run the pipeline against another zenit revision and every clip on these pages is regenerated from real windows; the REVISION file records which commit the current media came from.
