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.
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é.
É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.
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);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.
I-beam sur les champs, hand sur les boutons, grab qui devient grabbing pendant un glisser ; pointer capture et cursor overrides s’appliquent.
Un anneau de pression bleu tant que le bouton est maintenu ; un clic atomique laisse une pulsation de 180 ms après relâchement.
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.
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.
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);Carte de l’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 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.
fps1–120, 60 par défautok · width · height · frame_count · dropped_frames · duration_ms · file_sizeCompiler 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.
# 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.tsSi 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.
- 1Compiler les cibles en test mode
Exécutez
zig build -Dtest-mode=true storybook console-probe devtools-probe hello-button counter-reactivedans le checkout de zenit. - 2Un 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 avecZENIT_E2E_FILE_RPC_DIR, exécutezbun scripts/zenit/capture.ts <scenario>sur le même répertoire, puis tuez l’application. - 3Piloter sémantiquement, enregistrer
capture.ts importe directement le
e2e/client.tsde zenit, localise par test_id ou par texte de l’arbre, enregistre à 30 fps et vérifieokainsi queframe_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. - 4Recadrer, 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 danspublic/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