Replay & E2E Testing
Agents use refs for exploration and authoring. Replay scripts are deterministic runs that can be used for E2E testing.
Core model
Two-pass workflow:
- Agent pass: discover and interact with refs (
snapshot->click @e../fill @e..). - Deterministic pass: run recorded
.adscript withreplay.
Record a replay script
Enable recording during a session:
By default, on close, a replay script is written to:
You can also provide a custom output file path:
--save-scriptvalue is treated as a file path.- Parent directories are created automatically when they do not exist.
- For ambiguous bare values, use
--save-script=workflow.ador a path-like value such as./workflow.ad.
Run replay
-
Replay reads
.adscripts. -
A script without a terminal
closealready leaves its session active. For an existing script that does end inclose, pass--keep-sessionto suppress only that final action and continue with interactive commands in the same session:Interior
closeactions still run. The flag is intentionally unavailable totestbecause suite attempts own cleanup, and it is rejected for Maestro YAML because that runtime owns its lifecycle.
Run Maestro compatibility flows
Agent Device can run a supported subset of Maestro YAML through its typed Maestro compatibility runtime:
Supported subset:
- Flows:
launchApp;runFlowfile/inline with platform, visibility, and limited boolean conditions;onFlowStart/onFlowComplete;repeat.timesand retry. - Interactions:
tapOn,doubleTapOn,longPressOn,inputTexton the focused element,eraseText,openLink,hideKeyboard, basicpressKey, andback; selector targets poll until available and support recursiveindex,childOf,above,below,leftOf,rightOf,containsChild,containsDescendants, points, andoptional; outer command labels are metadata, not target selectors. - Assertions and navigation:
assertVisible,assertNotVisible,assertTrue(literal values and${VAR}lookups only;"","false","0","null", and"undefined"are falsy, everything else is truthy),extendedWaitUntil,scroll,scrollUntilVisible, absolute/percentage/targetswipe,takeScreenshot,waitForAnimationToEnd, andstopApp. - Scripts: ordered
runScriptfile/env scripts withhttp.post,json, andoutputvariables.
Boundaries:
- Runtime: iOS and Android only;
launchApp.clearStatesupports Android and iOS simulators, launch arguments are Apple-only, and standalone device utility/state commands are unsupported. - Expressions:
when.truesupports boolean literals andmaestro.platformcomparisons;assertTruesupports literal values and${VAR}lookups only;repeat.while,evalScript, and broader JavaScript expressions are unsupported. - Environment: flow
envis the default,AD_VAR_*overrides it, and CLI-e KEY=VALUEwins over both. - Failure diagnostics: resolved targets and
runFlowpaths are rendered, whileinputTextpayloads remain hidden; do not place secrets in diagnostic identifiers. - Trust:
runScriptexecutes trusted scripts, may makehttp.postnetwork requests, and is not a security sandbox; output keys cannot contain a dot. - Errors and tracking: unsupported commands and fields fail with source context when available; open a focused issue only when implementation work is planned.
- Session takeover:
--keep-sessionis a native.adreplay option and is rejected for Maestro YAML.
See ADR 0015 for architecture, performance tradeoffs, and deliberate deviations. If a missing feature matters for your suite, open a focused issue with a small flow snippet.
Export .ad scripts to Maestro YAML
Replay scripts can be exported to a Maestro YAML subset when you need to hand a recorded Agent Device flow to a Maestro runner:
replay export is a local file transform. It does not start the daemon or contact a device. If --out is omitted, the YAML is printed to stdout.
The exporter is intentionally strict. It writes Maestro YAML for compatible flow actions such as app launch, taps, long press, text input, keyboard dismiss/enter, back, text visibility assertions, coordinate swipes, basic scroll, screenshots, and .ad env directives. Agent-only inspection or maintenance actions such as snapshot, get, record, trace, settings, and unsupported selector shapes fail with the source line and action instead of being silently dropped. Known semantic differences are reported as warnings; for example, .ad fill exports as tapOn plus inputText, which may append text in Maestro rather than replacing existing field contents. Native .ad label= selectors export as Maestro text: selectors and warn because Maestro text matching is broader than label-only matching.
Run a lightweight .ad suite
testdiscovers.adfiles from files, directories, or globs and runs them serially.context platform=...inside each.adfile is the target source of truth for suite execution.--platformis a filter for suite discovery; files without platform metadata are skipped when a filter is present.context timeout=...andcontext retries=...can be declared per script; CLI flags override metadata. Retries are capped at3, and duplicate keys in the context header fail fast instead of silently overriding each other.- By default, suite artifacts are written under
.agent-device/test-artifacts/<run-id>/.... Each attempt writesreplay.ad,result.txt, andreplay-timing.ndjson. Failed attempts also keep copied logs and artifact files when the replay produced them. replay-timing.ndjsonrecords attempt, cleanup, and per-step start/stop events with durations. Upload it from CI even for passing runs when comparing local and CI performance.- Timeouts are cooperative: the runner marks the attempt failed at the timeout boundary, then gives the underlying replay a short grace period to stop before session cleanup.
- The default text reporter streams live progress on stderr while a suite runs, then prints the final summary, failed tests, and passed-on-retry flaky tests. Use
--verboseto include step traces in completed-test progress output. --reporteris repeatable. Built-ins aredefaultfor the console summary andjunit:<path>for JUnit XML. Passing any explicit reporter list replaces the implicit default reporter, so include--reporter defaultwhen you also want terminal output.--report-junit <path>remains a compatibility alias for--reporter junit:<path>.- When
--fail-fastand retries are both set, the current test still consumes its retries before the suite stops.
Custom test reporters
Custom reporters are CLI-only presentation adapters. The daemon streams progress and returns the structured replay suite result; reporters run in the local CLI process and can render both live progress and final output.
Reporter modules can export a reporter object, reporter, createReporter, or a default factory. Factories receive load context. Reporter hooks receive replay test domain objects and an IO context with stdout and stderr streams:
For a live terminal reporter that prints each completed test as an emoji, title, and duration:
TypeScript reporters use the same object shape; compile them to JavaScript before passing them to --reporter:
The CLI loads reporter modules with Node dynamic import(). Use .mjs or .js files at runtime; for TypeScript, compile the reporter to JavaScript before passing it to --reporter. Loading .ts files directly depends on Node's type-stripping behavior and is not part of the supported reporter contract.
Live reporter hooks are semantic: onSuiteStart, onTestStart, onTestStep, and onTestResult run while the daemon request is active; generic command progress frames are not exposed to test reporters. These live hooks are synchronous — they run from the progress stream as events arrive and are not awaited, so keep their work synchronous and defer anything async to onSuiteEnd, which the CLI awaits before exiting. onSuiteEnd receives the final suite result. getExitCode can only raise the suite exit code, never lower it: the highest reporter-provided code wins and failed tests still exit with 1 when no reporter raises it further, so a reporter cannot mask a failing suite.
Parametrise .ad scripts
Substitute ${VAR} tokens in .ad scripts using values from the CLI, shell env, script-local env directives, or built-ins.
Precedence
Built-ins
Built-ins are provided by replay/test runtime and use the reserved AD_* namespace.
AD_PLATFORM- matchescontext platform=...or the selected platform when availableAD_SESSION- active session nameAD_FILENAME- path of the running.adfileAD_DEVICE- device identifier (when--deviceis set)AD_ARTIFACTS- attempt artifacts directory (when running undertest)
User-defined keys starting with AD_ are rejected in env, -e, and shell imports such as AD_VAR_AD_FOO, so built-ins cannot be overridden.
Substitution happens inside parsed string values. It does not create extra arguments, so quote selectors or text values that contain spaces:
Fallback and escape
${VAR:-default} yields default when VAR is unset.
\${APP} emits a literal ${APP} with no substitution.
Recipes
Run one flow against two app variants in CI:
Tune timings locally without editing the script:
Extract a reusable selector. Before:
After:
Quote ${VAR} inside selector expressions so the whole expression is treated as a single argument.
Notes
- Shell env (
AD_VAR_*) is collected on the CLI/client side at request time, so the same values are seen whether the daemon runs locally or remotely. - No nested fallback.
${A:-${B}}is not supported. - Unresolved
${VAR}fails with afile:linereference. Typos are loud.
Replay divergence and resume
A failing replay/test step returns a structured REPLAY_DIVERGENCE error instead of a bare failure. The report carries, bounded and redacted:
step— the 1-based plan index and its source file/line (through MaestrorunFlowincludes).screen— a fresh post-failure snapshot digest with actionable refs, orunavailablewith a reason/hint when capture failed or was sparse (never a stale tree).suggestions— up to 5 ranked, re-resolved candidates for the failing selector (id match ranks above role+label, which ranks above label-only), each with abasisyou can inspect before acting.resume— whether and how to continue without re-running the script from the top.
Text output prints a compact summary of the same fields; --json/MCP carry the full object.
Resuming a failed replay
replay --from <n> --plan-digest <sha256> resumes at plan step n, not after it, skipping 1..n-1 without executing them. Both flags come from a divergence report's resume field — from is the failed step, planDigest is the digest of the exact unchanged plan that produced it.
Choose one recovery workflow:
- Change the replay script. Review the suggestion, edit the selector or include, then run a fresh full
replay ./flow.ad. The old digest is intentionally invalid after any plan edit; do not combine it with the edited script. A later divergence supplies a new digest. - Keep the replay plan unchanged. Repair app/device state so the reported failed step can succeed when retried, then resume with the report's unchanged
fromandplanDigest. If you manually complete the failed action itself, the reportedfromwill execute it again; only do that when repeating the action is safe.
The unchanged-plan resume loop is:
- Run
replay ./flow.ad. On failure, readresumefrom the divergence. - Leave the script, includes, platform, and target unchanged. Repair app state yourself so the failed step can be retried safely. The daemon never infers or reconstructs app state — it only skips execution of the earlier steps.
replay ./flow.ad --from <resume.from> --plan-digest <resume.planDigest>.
For Maestro flows, --from addresses the immutable top-level typed plan. Compact runtime control nodes
remain single plan steps and nested commands are not independently addressable. As with generic .ad
replay, the caller is responsible for restoring any state and environment values established by skipped
steps before resuming.
Passing --plan-digest that no longer matches the current script — because you edited it, an include changed, or platform-conditioned expansion differs — fails INVALID_ARGS before any action; run a fresh full replay to get a new digest. --from is replay-only; test rejects it (a suite run must stay full and deterministic).
--update/-u (retired)
--update/-u no longer rewrites .ad files. Historically it retried a failing step against the recorded selector's candidate material and rewrote the line in place; the audit behind ADR 0012 found that mechanism rarely able to act (it can only recover drift the original selector still matches, never a rename) and a silent rewrite is a target-binding risk on its own. The flag is kept, accepted, and is a complete no-op: every replay divergence already carries the same ranked suggestions the old heal path used to apply blind, whether or not --update is passed. Review a suggestion, then edit the .ad file yourself if it's right.
Troubleshooting
- Replay fails after UI/layout changes:
- Read the divergence report's
suggestionsand repair the selector by hand; there is no automated rewrite. Because the edit changes the plan digest, run a fresh full replay instead of using the old resume flags.
- Read the divergence report's
- Repeated re-runs are slow or the app is stateful, but the script is still correct:
- Leave the replay plan unchanged, repair app state so the reported failed step can be retried, then use its
--from/--plan-digest. Resume starts at--from; it does not skip that step.
- Leave the replay plan unchanged, repair app state so the reported failed step can be retried, then use its
- Replay file parse error:
- Validate quoting in
.adlines (unclosed quotes are rejected).
- Validate quoting in
- Maestro compatibility flow fails on unsupported syntax:
- Check ADR 0015. If the missing feature matters to your suite, open a focused issue with a small flow snippet.
