E2E 자동화 harness
시맨틱 로케이터로 실제 zenit 창을 구동하고, 가상 커서·PNG 스크린샷·H.264 녹화를 검토 가능한 증거로 만듭니다.
웹 대역이 아니라 실제 창을 테스트합니다
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를 만들지 않고 레이아웃이나 hit-test에도 참여하지 않으며, 현재 자동화 좌표와 해석된 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는 모든 자식을 반환하며(이전의 레벨당 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 인터페이스는 아직 이 두 필드를 선언하지 않으므로, 읽을 때 타입을 확장해야 합니다.
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가 주입한 hover 위치를 덮어씁니다. 앱 프로세스에 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가 구동과 녹화를 맡습니다.
- 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