v0.1.0-alpha
官网Home
docs/advanced/e2e
应用能力 · Real-window testingCapabilities · Real-window testing

E2E 自动化 HarnessThe E2E harness

用语义定位驱动真实的 Zenit 窗口,并把虚拟光标、PNG 截图与 H.264 录像变成可审阅的证据。Drive a real Zenit window by semantic locators, and turn the virtual cursor, PNG screenshots and H.264 recordings into reviewable evidence.

预计阅读 10 分钟 · 含测试录像10 min read · includes recordings

测试真实窗口,而不是网页替身Test the real window, not a web stand-in

Harness 只存在于以 -Dtest-mode=true 构建的应用里。TypeScript 控制器把请求写进私有的 file-RPC 目录,应用主线程在处理完平台事件后排空命令队列:点击仍然走真实的 hit-test,输入仍然走组件事件,截图读取的是已经呈现的 drawable。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
请求与响应都先写临时文件再 rename,另一端永远看不到写了一半的 JSON;虚拟光标在最后一步画进 render target,不进入 UI 树。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

优先用 test_id 定位,不要把像素坐标写死。需要展示真实光标轨迹时,用 screenPos 取语义节点当前的 rect,再发送细粒度的鼠标序列。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);
上面模式的实际运行:I-beam 聚焦、IME preedit / commit、切换到 RTL story、点击 Checkbox,全部由 Harness 驱动。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

系统截图通常抓不到物理光标,屏幕录制又会带上桌面、标题栏和权限提示。Zenit 的虚拟光标是最后一层 draw-only overlay:不创建 Node、不参与布局或命中,只把当前自动化坐标与解析后的 CursorShape 画进应用的 render target。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 just paints the current automation position and the resolved CursorShape into the app’s render target.

01
形态真实Real shapes

输入框上是 I-beam,按钮上是 hand,拖动从 grab 变为 grabbing;遵守 pointer capture 与 cursor override。I-beam over inputs, hand over buttons, grab turning into grabbing on drags; pointer capture and cursor overrides apply.

02
动作可读Readable actions

按住时出现蓝色 press ring;原子 click 释放后保留 180ms 的 pulse。A blue press ring while the button is held; an atomic click leaves a 180 ms pulse after release.

03
证据干净Clean evidence

Retina 应用内容加虚拟光标;没有桌面、物理光标、标题栏或屏幕共享标记。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
测试先通过 test_id 找到 checkbox,再由真实 hit-test 改变 Signal 状态;画面与回读的状态来自同一轮执行。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

指针拖动用 mouseDown / mouseMove / mouseUp 组合,经过和真实鼠标相同的拖动阈值、pointer capture 与手势仲裁。双击、三击就是在多击间隔内连续发送 clickAt。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);
Drag story:mouseDown → 分步 mouseMove → mouseUp,方块越过 4px 阈值后跟随光标。The Drag story: mouseDown → stepped mouseMove → mouseUp; the box follows once past the 4 px threshold.
Gestures story:两次 clickAt 间隔 160ms,手势仲裁识别为双击。The Gestures story: two clickAt calls 160 ms apart, recognised by the gesture arena as a double click.

常用 APIAPI map

目标GoalHarness APIHarness 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)
文本与 IMEText and IMEtype_ · imePreedit · imeCommit · inputState
可观测性ObservabilityconsoleEvents · waitForConsole · clearConsole · stats · resetTiming
视觉证据Visual evidencescreenshot · startWindowRecording · windowRecordingStatus · stopWindowRecording
等待与窗口Waiting and windowwaitForServerReady · waitFor · resizeWindow

tree 返回完整子节点列表(不再每层只留前 50 个;深度仍以 20 层为限),结果超过 256 KB 的结果缓冲时显式报错,而不是返回半截 JSON。query 命中的每个节点带 effective_opacity(自身 × 全部祖先的 opacity,display:none 计 0),display:none 的节点带 hidden: true。等浮层淡入时轮询到 effective_opacity >= 0.99 再点击,比 sleep 猜动画时长可靠。typed client 的 QueryNode 接口目前还没声明这两个字段,读取时需要自己扩展类型。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.

drawable 直录Recording the drawable

macOS 录制路径在 GPU 上把完成的 Metal drawable blit 进 AVAssetWriterInputPixelBufferAdaptor 的 buffer pool,再由 AVFoundation 编码为 H.264;没有 CPU 像素回读,也不需要「屏幕录制」权限。stopWindowRecording 是同步边界:返回时 MP4 的 index 与最后一帧都已写完。On macOS, the recorder blits each finished Metal drawable on the GPU into an AVAssetWriterInputPixelBufferAdaptor buffer pool, and AVFoundation encodes H.264 — no CPU pixel readback, 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必须是绝对路径的 .mp4,且父目录已经存在Must be an absolute .mp4 path whose parent directory already exists
fps1–120,默认 601–120, default 60
窗口尺寸Window size录制期间不能 resize;单个 H.264 track 不能中途改变像素尺寸No 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 的依赖选项是隔离的:下游项目要在自己的 build.zig 里把 test-mode 与 e2e-port 转发给 zenit 依赖,并调用 zenit.installHarnessClient(b, zenit_dep),把 typed client 安装到 zig-out/share/zenit/harness/client.ts。快速开始里的模板已经做好了这两件事。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

边跑测试边用电脑时,真实鼠标的移动、按键和滚轮会覆盖 Harness 注入的悬停位置。给应用进程设 ZENIT_E2E_ISOLATE_POINTER=1,test-mode 构建会丢弃系统的 mouse move / button / wheel / magnify 事件,只认 Harness 注入的输入;zenit 自己的 scripts/run_storybook_e2e.sh 默认开启,需要真实鼠标参与时设为 0。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.

正常发布不传 -Dtest-mode=true:test_harness.enabled 是编译期常量,Harness 初始化与 file-RPC 端点会被整个消除。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

本站所有截图和录像都来自真实窗口,由两个脚本生成:scripts/zenit/run-capture.sh 负责构建和进程编排,scripts/zenit/capture.ts 负责驱动与录制。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
    以 test-mode 构建目标Build the targets in test mode

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

  2. 2
    每个场景一个私有 RPC 目录A private RPC dir per scenario

    用 mktemp -d 新建目录,带着 ZENIT_E2E_FILE_RPC_DIR 在后台启动 .app 里的二进制,再用同一个目录运行 bun scripts/zenit/capture.ts <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 直接 import zenit 的 e2e/client.ts,按 test_id 或树中的文本定位,以 30fps 录制,并断言 ok 且 frame_count ≥ 2;某个 story 的交互失败时退化为通用光标扫过并记录警告,不中断整批。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 裁到 Storybook 详情区域并重编码为 H.264(faststart),写入 public/media,最后把 zenit 的 commit 写进 public/media/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-licensed开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。Free 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 5f9add5+wip 2026-09-30