---
title: "E2E harness — zenit Zig UI ドキュメント"
description: "セマンティックなロケーターで実際の zenit ウィンドウを操作し、仮想カーソル、PNG スクリーンショット、H.264 録画をレビュー可能な証拠に変えます。"
url: https://zenit.z.express/ja/docs/advanced/e2e
language: ja
alternate_en: https://zenit.z.express/docs/advanced/e2e.md
alternate_zh: https://zenit.z.express/zh/docs/advanced/e2e.md
alternate_es: https://zenit.z.express/es/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 録画をレビュー可能な証拠に変えます。

## Web の代用品ではなく、実ウィンドウをテストする

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 が表示され、アトミックなクリックでは離した後に 180 ms の 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/ja/components/drag) story：mouseDown → 段階的な mouseMove → mouseUp。4 px のしきい値を超えるとボックスがカーソルに追従します。

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

[Gestures](https://zenit.z.express/ja/components/multiclick) story：160 ms 間隔の 2 回の clickAt を、ジェスチャーアリーナがダブルクリックとして認識します。

> WARNING
> 
> **dragAt はポインタのドラッグではありません。** `dragAt(x, y, kind, paths)` は（Finder からのような）**ファイル**のドラッグ＆ドロップセッションをシミュレートします。`kind` の 0–3 は entered / updated / exited / dropped を表します。UI 要素を動かすには上のマウス操作列を使ってください。

## 主な 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` はすべての子を返します（以前の 1 階層あたり 50 個の上限はなくなりました。深さは引き続き 20 まで）。256 KB の結果バッファを超える結果は、途中までの JSON ではなく明示的なエラーになります。`query` にマッチした各ノードは `effective_opacity`（自身の opacity × すべての祖先の opacity、`display:none` の下では 0）を持ち、`display:none` のノードは `hidden: true` を持ちます。overlay のフェードインを待つときは、推測した時間だけ sleep するのではなく、`effective_opacity >= 0.99` になるまでポーリングしてからクリックしてください。typed client の `QueryNode` インターフェースはまだこの 2 つのフィールドを宣言していないため、読み取る際は型を拡張してください。

## drawable の直接録画

macOS では、レコーダーが完成した Metal drawable を GPU 上で `AVAssetWriterInputPixelBufferAdaptor` の buffer pool に blit し、AVFoundation が H.264 にエンコードします。CPU でのピクセル読み戻しも「画面収録」の権限も不要です。`stopWindowRecording` は同期の境界で、戻った時点で MP4 の index と最後のフレームは書き込み済みです。

| 制約 | 説明 |
| --- | --- |
| 出力パス | 親ディレクトリが既に存在する、絶対パスの .mp4 であること |
| `fps` | 1–120、デフォルト 60 |
| ウィンドウサイズ | 録画中は resize 不可。1 本の H.264 トラックは途中でピクセルサイズを変えられない |
| 結果 | `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/ja/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 ディレクトリを 1 つ。** アプリとコントローラーは同じ `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 エンドポイントはまるごと取り除かれます。

## 実例：このサイトのメディアパイプライン

このサイトのスクリーンショットと録画はすべて実ウィンドウから、2 つのスクリプトで生成しています。`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 は zenit の `e2e/client.ts` を直接 import し、test\_id またはツリー内のテキストで位置を特定し、30 fps で録画して `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 ファイルには現在のメディアがどのコミットに由来するかが記録されています。
