docs/advanced/e2e
アプリ機能 · Real-window testing

E2E 自動化 harness

セマンティックなロケーターで実際の zenit ウィンドウを操作し、仮想カーソル、PNG スクリーンショット、H.264 録画をレビュー可能な証拠に変えます。

約 10 分で読めます · 録画あり

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
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 が駆動しています。

仮想カーソルが重要な理由

システムのスクリーンショットでは物理カーソルが写らないことが多く、画面収録ではデスクトップ、タイトルバー、権限ダイアログまで入り込みます。zenit の仮想カーソルは最後に重なる draw-only overlay です。Node を作らず、レイアウトにもヒットテストにも関与せず、現在の自動化座標と解決済みの CursorShape をアプリの render target に描くだけです。

01
本物の形状

入力欄では I-beam、ボタンでは hand、ドラッグでは grab から grabbing へ。pointer capture と cursor override も反映されます。

02
読み取れる操作

ボタンを押している間は青い press ring が表示され、アトミックなクリックでは離した後に 180 ms の pulse が残ります。

03
クリーンな証拠

Retina のアプリ内容と仮想カーソルだけ。デスクトップ、物理カーソル、タイトルバー、画面共有バッジは入りません。

A Zenit Storybook checkbox turning checked after the harness's virtual hand cursor clicks it
テストは test_id で checkbox を見つけ、実際の hit-test がその Signal を切り替えます。画面と読み戻した状態は同じ実行から得られたものです。

ドラッグとマルチクリック

ポインタのドラッグは mouseDown / mouseMove / mouseUp の組み合わせで、物理マウスと同じドラッグしきい値、pointer capture、ジェスチャーアリーナを通ります。ダブルクリックやトリプルクリックは、マルチクリック間隔内に clickAt を連続して呼ぶだけです。

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。4 px のしきい値を超えるとボックスがカーソルに追従します。
Gestures story:160 ms 間隔の 2 回の clickAt を、ジェスチャーアリーナがダブルクリックとして認識します。

主な API

目的Harness API
セマンティックな検索tree · query(testId) · screenPos · focused
ポインタとキーボードclickTestId · clickAt · mouseDown/Move/Up · scrollAt · magnifyAt · key
ファイルのドラッグ&ドロップdragAt(x, y, kind, paths)
テキストと IMEtype_ · 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 であること
fps1–120、デフォルト 60
ウィンドウサイズ録画中は resize 不可。1 本の H.264 トラックは途中でピクセルサイズを変えられない
結果ok · width · height · frame_count · dropped_frames · duration_ms · file_size

ビルドと実行

Zig の依存オプションは分離されているため、利用側プロジェクトは自分の build.zig で test-mode と e2e-port を zenit 依存へ転送し、zenit.installHarnessClient(b, zenit_dep) を呼んで typed client を zig-out/share/zenit/harness/client.ts にインストールする必要があります。クイックスタートのテンプレートはこの両方を済ませています。

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 に設定してください。

通常のリリースビルドでは -Dtest-mode=true を渡しません。test_harness.enabled はコンパイル時定数なので、harness の初期化と file-RPC エンドポイントはまるごと取り除かれます。

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

このサイトのスクリーンショットと録画はすべて実ウィンドウから、2 つのスクリプトで生成しています。scripts/zenit/run-capture.sh がビルドとプロセスの編成を、scripts/zenit/capture.ts が操作と録画を担当します。

  1. 1
    test-mode でターゲットをビルド

    zenit の checkout で zig build -Dtest-mode=true storybook console-probe devtools-probe hello-button counter-reactive を実行します。

  2. 2
    シナリオごとにプライベートな RPC ディレクトリ

    mktemp -d でディレクトリを作り、ZENIT_E2E_FILE_RPC_DIR を付けて .app 内のバイナリをバックグラウンドで起動し、同じディレクトリで bun scripts/zenit/capture.ts <scenario> を実行してから、アプリを終了させます。

  3. 3
    セマンティックに操作して録画

    capture.ts は zenit の e2e/client.ts を直接 import し、test_id またはツリー内のテキストで位置を特定し、30 fps で録画して ok と frame_count ≥ 2 をアサートします。ある story の操作が失敗した場合は汎用のカーソルスイープにフォールバックして警告を記録し、バッチ全体は中断しません。

  4. 4
    切り抜き、トランスコード、リビジョン記録

    ffmpeg で Storybook の詳細ペインに切り抜いて H.264(faststart)に再エンコードし、public/media に書き出します。最後に zenit の commit を 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 · デュアルライセンスオープンソースプロジェクトは GPL-3.0-only のもとで無料で使えます。クローズドソースや商用製品には商用ライセンスが必要です。作者への連絡先:zongyi.xzy#gmail.com(# を @ に置き換え)zenit 5f9add5+wip 2026-09-30