docs/advanced/e2e
Capacités · Tests en fenêtre réelle

Le harness E2E

Pilotez une vraie fenêtre zenit par localisateurs sémantiques, et transformez le curseur virtuel, les captures PNG et les enregistrements H.264 en preuves vérifiables.

10 min de lecture · avec enregistrements

Testez la vraie fenêtre, pas un substitut web

Le harness n’existe que dans les applications compilées avec -Dtest-mode=true. Un contrôleur TypeScript écrit des requêtes dans un répertoire file-RPC privé, et le thread principal de l’application vide la file de commandes après les événements de la plateforme : les clics passent toujours par un vrai hit-testing, la saisie par les événements des composants, et les captures lisent le drawable réellement présenté.

HARNESS PATH
Requêtes et réponses sont écrites dans un fichier temporaire puis renommées : l’autre côté ne voit jamais de JSON à moitié écrit. Le curseur virtuel est peint dans le render target tout à la fin et n’entre jamais dans l’arbre d’UI.

Écrire un premier test en fenêtre réelle

Localisez par test_id plutôt qu’avec des pixels codés en dur. Pour un trajet de curseur visible, lisez le rect courant du nœud avec screenPos et envoyez une séquence de souris fine.

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);
Le modèle ci-dessus, exécuté pour de vrai : focus I-beam, IME preedit / commit, passage à la story RTL et clic sur un Checkbox — le tout piloté par le harness.

Pourquoi le curseur virtuel compte

Les captures système manquent généralement le curseur physique, et l’enregistrement d’écran embarque le bureau, la barre de titre et les demandes d’autorisation. Le curseur virtuel de zenit est un overlay final en dessin seul : il ne crée aucun Node et ne participe ni au layout ni au hit-testing — il peint simplement la position d’automatisation courante et le CursorShape résolu dans le render target de l’application.

01
Formes réelles

I-beam sur les champs, hand sur les boutons, grab qui devient grabbing pendant un glisser ; pointer capture et cursor overrides s’appliquent.

02
Actions lisibles

Un anneau de pression bleu tant que le bouton est maintenu ; un clic atomique laisse une pulsation de 180 ms après relâchement.

03
Preuves propres

Le contenu Retina de l’application plus le curseur virtuel — ni bureau, ni curseur physique, ni barre de titre, ni badge de partage d’écran.

A Zenit Storybook checkbox turning checked after the harness's virtual hand cursor clicks it
Le test trouve la checkbox par test_id, et un vrai hit-test bascule son Signal ; les pixels et l’état relu proviennent de la même exécution.

Glisser et clics multiples

Les glisser de pointeur sont des séquences mouseDown / mouseMove / mouseUp et passent par le même seuil de glisser, pointer capture et arène de gestes qu’une souris physique. Les doubles et triples clics sont des appels consécutifs à clickAt dans l’intervalle de clic multiple.

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);
La story Drag : mouseDown → mouseMove par étapes → mouseUp ; la boîte suit une fois le seuil de 4 px franchi.
La story Gestures : deux appels à clickAt espacés de 160 ms, reconnus par l’arène de gestes comme un double clic.

Carte de l’API

ObjectifAPI du harness
Recherche sémantiquetree · query(testId) · screenPos · focused
Pointeur et clavierclickTestId · clickAt · mouseDown/Move/Up · scrollAt · magnifyAt · key
Glisser-déposer de fichiersdragAt(x, y, kind, paths)
Texte et IMEtype_ · imePreedit · imeCommit · inputState
ObservabilitéconsoleEvents · waitForConsole · clearConsole · stats · resetTiming
Preuves visuellesscreenshot · startWindowRecording · windowRecordingStatus · stopWindowRecording
Attente et fenêtrewaitForServerReady · waitFor · resizeWindow

tree renvoie tous les enfants (l’ancienne limite de 50 enfants par niveau a disparu ; la profondeur reste plafonnée à 20), et un résultat qui dépasse le tampon de résultat de 256 KB produit une erreur explicite plutôt qu’un demi-document JSON. Chaque nœud trouvé par query porte effective_opacity — sa propre opacité multipliée par celle de tous ses ancêtres, 0 sous display:none — et les nœuds en display:none portent hidden: true. Pour attendre qu’un overlay apparaisse en fondu, interrogez jusqu’à effective_opacity >= 0.99 avant de cliquer, plutôt que de dormir une durée devinée. L’interface QueryNode du client typé ne déclare pas encore ces deux champs : étendez le type pour les lire.

Enregistrer le drawable

Sur macOS, l’enregistreur copie (blit) sur le GPU chaque Metal drawable terminé dans un buffer pool AVAssetWriterInputPixelBufferAdaptor, et AVFoundation encode en H.264 — aucune relecture de pixels côté CPU, aucune autorisation d’enregistrement d’écran. stopWindowRecording est une frontière synchrone : à son retour, l’index du MP4 et la dernière image sont écrits.

ContrainteDétail
Chemin de sortieDoit être un chemin .mp4 absolu dont le répertoire parent existe déjà
fps1–120, 60 par défaut
Taille de fenêtrePas de redimensionnement pendant l’enregistrement ; une piste H.264 ne peut pas changer de taille en pixels en cours de flux
Résultatok · width · height · frame_count · dropped_frames · duration_ms · file_size

Compiler et lancer

Les options de dépendance de Zig sont isolées : un build.zig en aval doit donc transmettre test-mode et e2e-port à la dépendance zenit et appeler zenit.installHarnessClient(b, zenit_dep), qui installe le client typé dans zig-out/share/zenit/harness/client.ts. Le modèle du Démarrage rapide fait déjà les deux.

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

Si vous utilisez la machine pendant les tests, les déplacements, boutons et molette de la souris physique écrasent la position de survol injectée par le harness. Définissez ZENIT_E2E_ISOLATE_POINTER=1 sur le processus de l’application : une build test-mode ignore alors les événements système mouse move / button / wheel / magnify et n’accepte que les entrées du harness ; le scripts/run_storybook_e2e.sh de zenit l’active par défaut — mettez-le à 0 quand une vraie souris doit intervenir.

Les builds de release normales omettent -Dtest-mode=true : test_harness.enabled est une constante de compilation, si bien que l’initialisation du harness et le point d’accès file-RPC sont entièrement éliminés à la compilation.

Exemple réel : le pipeline média de ce site

Chaque capture et chaque clip de ce site provient d’une vraie fenêtre, produit par deux scripts : scripts/zenit/run-capture.sh compile et orchestre les processus, et scripts/zenit/capture.ts pilote et enregistre.

  1. 1
    Compiler les cibles en test mode

    Exécutez zig build -Dtest-mode=true storybook console-probe devtools-probe hello-button counter-reactive dans le checkout de zenit.

  2. 2
    Un répertoire RPC privé par scénario

    Créez un répertoire avec mktemp -d, lancez en arrière-plan le binaire contenu dans le .app avec ZENIT_E2E_FILE_RPC_DIR, exécutez bun scripts/zenit/capture.ts <scenario> sur le même répertoire, puis tuez l’application.

  3. 3
    Piloter sémantiquement, enregistrer

    capture.ts importe directement le e2e/client.ts de zenit, localise par test_id ou par texte de l’arbre, enregistre à 30 fps et vérifie ok ainsi que frame_count ≥ 2 ; si l’interaction d’une story échoue, il se rabat sur un balayage générique du curseur et consigne un avertissement au lieu d’interrompre le lot.

  4. 4
    Recadrer, transcoder, noter la révision

    ffmpeg recadre sur le panneau de détail de Storybook et réencode en H.264 (faststart) dans public/media ; enfin, le commit de zenit est écrit dans 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 · Double licenceGratuit pour les projets open source sous GPL-3.0-only ; les produits propriétaires ou commerciaux nécessitent une licence commerciale.Contacter l’auteur : zongyi.xzy#gmail.com (remplacez # par @)zenit 5f9add5+wip 2026-09-30