E2E 自動化 harness
セマンティックなロケーターで実際の zenit ウィンドウを操作し、仮想カーソル、PNG スクリーンショット、H.264 録画をレビュー可能な証拠に変えます。
Web の代用品ではなく、実ウィンドウをテストする
harness は -Dtest-mode=true でビルドしたアプリにだけ存在します。TypeScript のコントローラーがプライベートな file-RPC ディレクトリにリクエストを書き込み、アプリのメインスレッドはプラットフォームイベントを処理した後にコマンドキューを空にします。クリックは実際の hit-test を通り、入力はコンポーネントのイベントを通り、スクリーンショットは実際に表示された drawable を読み取ります。
最初の実ウィンドウテストを書く
ピクセル座標を決め打ちせず、test_id で位置を特定します。カーソルの軌跡を見せたい場合は、screenPos でノードの現在の rect を取得し、細かいマウス操作の列を送ります。
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);仮想カーソルが重要な理由
システムのスクリーンショットでは物理カーソルが写らないことが多く、画面収録ではデスクトップ、タイトルバー、権限ダイアログまで入り込みます。zenit の仮想カーソルは最後に重なる draw-only overlay です。Node を作らず、レイアウトにもヒットテストにも関与せず、現在の自動化座標と解決済みの CursorShape をアプリの render target に描くだけです。
入力欄では I-beam、ボタンでは hand、ドラッグでは grab から grabbing へ。pointer capture と cursor override も反映されます。
ボタンを押している間は青い press ring が表示され、アトミックなクリックでは離した後に 180 ms の pulse が残ります。
Retina のアプリ内容と仮想カーソルだけ。デスクトップ、物理カーソル、タイトルバー、画面共有バッジは入りません。
ドラッグとマルチクリック
ポインタのドラッグは mouseDown / mouseMove / mouseUp の組み合わせで、物理マウスと同じドラッグしきい値、pointer capture、ジェスチャーアリーナを通ります。ダブルクリックやトリプルクリックは、マルチクリック間隔内に clickAt を連続して呼ぶだけです。
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);主な API
tree · query(testId) · screenPos · focusedclickTestId · clickAt · mouseDown/Move/Up · scrollAt · magnifyAt · keydragAt(x, y, kind, paths)type_ · imePreedit · imeCommit · inputStateconsoleEvents · waitForConsole · clearConsole · stats · resetTimingscreenshot · startWindowRecording · windowRecordingStatus · stopWindowRecordingwaitForServerReady · waitFor · resizeWindowtree はすべての子を返します(以前の 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 と最後のフレームは書き込み済みです。
fps1–120、デフォルト 60ok · 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 にインストールする必要があります。クイックスタートのテンプレートはこの両方を済ませています。
# 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"# 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 が操作と録画を担当します。
- 1test-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に書き込みます。
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