---
title: "E2E 自动化 — zenit Zig UI 文档"
description: "用语义定位驱动真实的 Zenit 窗口，并把虚拟光标、PNG 截图与 H.264 录像变成可审阅的证据。"
url: https://zenit.z.express/zh/docs/advanced/e2e
language: zh-CN
alternate_en: https://zenit.z.express/docs/advanced/e2e.md
alternate_es: https://zenit.z.express/es/docs/advanced/e2e.md
alternate_ja: https://zenit.z.express/ja/docs/advanced/e2e.md
alternate_ko: https://zenit.z.express/ko/docs/advanced/e2e.md
alternate_fr: https://zenit.z.express/fr/docs/advanced/e2e.md
alternate_de: https://zenit.z.express/de/docs/advanced/e2e.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# E2E 自动化 Harness

用语义定位驱动真实的 Zenit 窗口，并把虚拟光标、PNG 截图与 H.264 录像变成可审阅的证据。

## 测试真实窗口，而不是网页替身

Harness 只存在于以 `-Dtest-mode=true` 构建的应用里。TypeScript 控制器把请求写进私有的 file-RPC 目录，应用主线程在处理完平台事件后排空命令队列：点击仍然走真实的 hit-test，输入仍然走组件事件，截图读取的是已经呈现的 drawable。

HARNESS PATH

请求与响应都先写临时文件再 rename，另一端永远看不到写了一半的 JSON；虚拟光标在最后一步画进 render target，不进入 UI 树。

## 写第一个真实窗口测试

优先用 `test_id` 定位，不要把像素坐标写死。需要展示真实光标轨迹时，用 `screenPos` 取语义节点当前的 rect，再发送细粒度的鼠标序列。

`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://zenit.z.express/media/e2e-text-ime.mp4?v=efa93e6b3f)

上面模式的实际运行：I-beam 聚焦、IME preedit / commit、切换到 RTL story、点击 Checkbox，全部由 Harness 驱动。

## 虚拟光标为什么重要

系统截图通常抓不到物理光标，屏幕录制又会带上桌面、标题栏和权限提示。Zenit 的虚拟光标是最后一层 draw-only overlay：不创建 Node、不参与布局或命中，只把当前自动化坐标与解析后的 `CursorShape` 画进应用的 render target。

**形态真实**

输入框上是 I-beam，按钮上是 hand，拖动从 grab 变为 grabbing；遵守 pointer capture 与 cursor override。

**动作可读**

按住时出现蓝色 press ring；原子 click 释放后保留 180ms 的 pulse。

**证据干净**

Retina 应用内容加虚拟光标；没有桌面、物理光标、标题栏或屏幕共享标记。

[![A Zenit Storybook checkbox turning checked after the harness's virtual hand cursor clicks it](https://zenit.z.express/media/e2e-virtual-cursor.png?v=ae67df2a38)](https://zenit.z.express/media/e2e-virtual-cursor.png?v=ae67df2a38)

测试先通过 test\_id 找到 checkbox，再由真实 hit-test 改变 Signal 状态；画面与回读的状态来自同一轮执行。

## 拖动与多击

指针拖动用 `mouseDown` / `mouseMove` / `mouseUp` 组合，经过和真实鼠标相同的拖动阈值、pointer capture 与手势仲裁。双击、三击就是在多击间隔内连续发送 `clickAt`。

`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://zenit.z.express/media/stories/drag.mp4?v=224e651da9)

[Drag](https://zenit.z.express/zh/components/drag) story：mouseDown → 分步 mouseMove → mouseUp，方块越过 4px 阈值后跟随光标。

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

[Gestures](https://zenit.z.express/zh/components/multiclick) story：两次 clickAt 间隔 160ms，手势仲裁识别为双击。

> WARNING
> 
> **dragAt 不是指针拖动。** `dragAt(x, y, kind, paths)` 模拟的是从 Finder 拖入**文件**的拖放会话，`kind` 取 0–3，依次表示 entered / updated / exited / dropped。移动界面元素请用上面的鼠标序列。

## 常用 API

| 目标 | Harness API |
| --- | --- |
| 语义定位 | `tree` · `query(testId)` · `screenPos` · `focused` |
| 指针与键盘 | `clickTestId` · `clickAt` · `mouseDown/Move/Up` · `scrollAt` · `magnifyAt` · `key` |
| 文件拖放 | `dragAt(x, y, kind, paths)` |
| 文本与 IME | `type_` · `imePreedit` · `imeCommit` · `inputState` |
| 可观测性 | `consoleEvents` · `waitForConsole` · `clearConsole` · `stats` · `resetTiming` |
| 视觉证据 | `screenshot` · `startWindowRecording` · `windowRecordingStatus` · `stopWindowRecording` |
| 等待与窗口 | `waitForServerReady` · `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` 接口目前还没声明这两个字段，读取时需要自己扩展类型。

## drawable 直录

macOS 录制路径在 GPU 上把完成的 Metal drawable blit 进 `AVAssetWriterInputPixelBufferAdaptor` 的 buffer pool，再由 AVFoundation 编码为 H.264；没有 CPU 像素回读，也不需要「屏幕录制」权限。`stopWindowRecording` 是同步边界：返回时 MP4 的 index 与最后一帧都已写完。

| 约束 | 说明 |
| --- | --- |
| 输出路径 | 必须是绝对路径的 .mp4，且父目录已经存在 |
| `fps` | 1–120，默认 60 |
| 窗口尺寸 | 录制期间不能 resize；单个 H.264 track 不能中途改变像素尺寸 |
| 结果 | `ok` · `width` · `height` · `frame_count` · `dropped_frames` · `duration_ms` · `file_size` |

> WARNING
> 
> **需要 resize 时先停。** resize 会让录制以错误结束。先 stop，调整窗口，再重新 start 一个新文件。

## 构建与运行

Zig 的依赖选项是隔离的：下游项目要在自己的 `build.zig` 里把 `test-mode` 与 `e2e-port` 转发给 zenit 依赖，并调用 `zenit.installHarnessClient(b, zenit_dep)`，把 typed client 安装到 `zig-out/share/zenit/harness/client.ts`。[快速开始](https://zenit.z.express/zh/docs/guide/getting-started)里的模板已经做好了这两件事。

```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
> 
> **每次运行一个 RPC 目录。** 应用与控制器必须拿到同一个 `ZENIT_E2E_FILE_RPC_DIR`；不设置时双方都退回 `/tmp/zenit_e2e_rpc_19816`（端口号来自 `-De2e-port`）。服务端会在目录里写 `owner.json` 作为单主锁：另一个存活实例已占用该目录时，新实例拒绝应答，而不是和它随机抢请求。

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

正常发布不传 `-Dtest-mode=true`：`test_harness.enabled` 是编译期常量，Harness 初始化与 file-RPC 端点会被整个消除。

## 真实案例：本站的媒体流水线

本站所有截图和录像都来自真实窗口，由两个脚本生成：`scripts/zenit/run-capture.sh` 负责构建和进程编排，`scripts/zenit/capture.ts` 负责驱动与录制。

1.  **以 test-mode 构建目标**
    
    在 zenit checkout 里执行 `zig build -Dtest-mode=true storybook console-probe devtools-probe hello-button counter-reactive`。
    
2.  **每个场景一个私有 RPC 目录**
    
    用 `mktemp -d` 新建目录，带着 `ZENIT_E2E_FILE_RPC_DIR` 在后台启动 .app 里的二进制，再用同一个目录运行 `bun scripts/zenit/capture.ts <scenario>`，结束后杀掉应用进程。
    
3.  **语义驱动并录制**
    
    capture.ts 直接 import zenit 的 `e2e/client.ts`，按 test\_id 或树中的文本定位，以 30fps 录制，并断言 `ok` 且 `frame_count ≥ 2`；某个 story 的交互失败时退化为通用光标扫过并记录警告，不中断整批。
    
4.  **裁剪、转码、记录版本**
    
    用 ffmpeg 裁到 Storybook 详情区域并重编码为 H.264（faststart），写入 `public/media`，最后把 zenit 的 commit 写进 `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
> 
> **这份文档可以复现。** 换一个 zenit revision 重新运行流水线，页面上的每段录像都会从真实窗口重新生成；REVISION 文件说明当前媒体来自哪个提交。
