docs/advanced/e2e
앱 기능 · Real-window testing

E2E 자동화 harness

시맨틱 로케이터로 실제 zenit 창을 구동하고, 가상 커서·PNG 스크린샷·H.264 녹화를 검토 가능한 증거로 만듭니다.

약 10분 분량 · 녹화 포함

웹 대역이 아니라 실제 창을 테스트합니다

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를 만들지 않고 레이아웃이나 hit-test에도 참여하지 않으며, 현재 자동화 좌표와 해석된 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 간격의 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는 모든 자식을 반환하며(이전의 레벨당 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와 마지막 프레임이 모두 기록되어 있습니다.

제약설명
출력 경로부모 디렉터리가 이미 존재하는 절대 경로 .mp4여야 함
fps1–120, 기본값 60
창 크기녹화 중 resize 불가. 하나의 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가 주입한 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가 구동과 녹화를 맡습니다.

  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