---
title: "E2E harness — zenit Zig UI 문서"
description: "시맨틱 로케이터로 실제 zenit 창을 구동하고, 가상 커서·PNG 스크린샷·H.264 녹화를 검토 가능한 증거로 만듭니다."
url: https://zenit.z.express/ko/docs/advanced/e2e
language: ko
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_ja: https://zenit.z.express/ja/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 녹화를 검토 가능한 증거로 만듭니다.

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

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를 만들지 않고 레이아웃이나 hit-test에도 참여하지 않으며, 현재 자동화 좌표와 해석된 `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/ko/components/drag) story: mouseDown → 단계별 mouseMove → mouseUp. 4 px 임계값을 넘으면 상자가 커서를 따라갑니다.

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

[Gestures](https://zenit.z.express/ko/components/multiclick) story: 160 ms 간격의 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`는 모든 자식을 반환하며(이전의 레벨당 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여야 함 |
| `fps` | 1–120, 기본값 60 |
| 창 크기 | 녹화 중 resize 불가. 하나의 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/ko/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 디렉터리 하나.** 앱과 컨트롤러는 같은 `ZENIT_E2E_FILE_RPC_DIR`를 봐야 합니다. 설정하지 않으면 둘 다 `/tmp/zenit_e2e_rpc_19816`(접미사는 `-De2e-port`)로 대체됩니다. 서버는 디렉터리에 `owner.json`을 단일 소유자 잠금으로 씁니다. 살아 있는 다른 인스턴스가 이미 그 디렉터리를 소유하고 있으면, 새 인스턴스는 요청을 놓고 경쟁하는 대신 응답을 거부합니다.

테스트가 도는 동안 컴퓨터를 쓰면, 실제 마우스의 이동·버튼·휠이 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.  **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 파일에는 현재 미디어가 어느 커밋에서 나왔는지 기록됩니다.
