Skip to content

refactor: replace Maestro compat with typed direct engine#1217

Merged
thymikee merged 83 commits into
mainfrom
feat/typed-maestro-engine
Jul 15, 2026
Merged

refactor: replace Maestro compat with typed direct engine#1217
thymikee merged 83 commits into
mainfrom
feat/typed-maestro-engine

Conversation

@thymikee

@thymikee thymikee commented Jul 11, 2026

Copy link
Copy Markdown
Member

Summary

Replace the production Maestro YAML lowering/replay trampoline with a source-preserving typed program, immutable replay plan, and direct runtime port.

The new engine owns Maestro control flow, variables, target policy, assertions, and lifecycle semantics while reusing shared agent-device gesture and platform backends. The old SessionAction conversion, private __maestro commands, positional decoding, hidden caches, recursive dispatch, and runtime fallback were removed. Android snapshots now fail closed on missing, invalid, stale, or system-chrome-only helper output; persistent-to-command-scoped fallback remains within the same bundled helper contract and never invokes the legacy snapshot engine.

iOS snapshot presentation also narrows React Native LogBox/RedBox Dismiss targets to the innermost action geometry for all consumers, preventing wrapper bounds from misrouting taps.

Validation

  • Android API 36 Pixel 9 Pro XL: react-native-pager-view 9/9 in 430.4s; react-navigation 38/38 in 493.3s. Snapshot evidence reported android-helper 0.19.3 with persistent-session transport.
  • iOS 26.2 simulator: react-navigation 38/38 in 814.6s.
  • iOS 26.2 simulator: react-native-pager-view 4/9. All five remaining failures reproduce unchanged with upstream Maestro 2.5.1 on the freshly rebuilt app, including the suite cleanup label mismatch; no agent-device-only divergence remains.
  • Review fixes: focused Maestro, gesture geometry, PNG worker, and replay failure suites passed 182/182; build, format, typecheck, lint, and fallow passed.
  • The aggregate unit and coverage runs were affected by host-load timeouts in unrelated Metro, runtime-hint, Apple cache, and recording fixtures. The review-related Maestro replay file passed 69/69 in isolation, and all changed tests passed in the coverage run.

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-15 19:26 UTC

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.7 MB 1.7 MB +26.6 kB
JS gzip 550.9 kB 557.5 kB +6.6 kB
npm tarball 665.2 kB 673.0 kB +7.8 kB
npm unpacked 2.3 MB 2.4 MB +31.3 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.8 ms 26.8 ms -0.0 ms
CLI --help 58.2 ms 57.6 ms -0.6 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/session.js +25.7 kB +6.7 kB
dist/src/internal/daemon.js +3.8 kB +1.1 kB
dist/src/registry.js +1.5 kB +539 B
dist/src/tv-remote.js -816 B -452 B
dist/src/runner-client.js +564 B +146 B

@thymikee
thymikee force-pushed the feat/typed-maestro-engine branch 2 times, most recently from 25a935c to 2260149 Compare July 12, 2026 11:18
@thymikee
thymikee force-pushed the agent/unified-gesture-architecture branch 2 times, most recently from c127ba2 to c8cf31f Compare July 13, 2026 09:18
Base automatically changed from agent/unified-gesture-architecture to main July 13, 2026 11:16
@thymikee
thymikee force-pushed the feat/typed-maestro-engine branch 2 times, most recently from 8aaeade to 7352d32 Compare July 14, 2026 04:42
@thymikee thymikee changed the title refactor: add direct Maestro compatibility engine foundation refactor: replace Maestro compat with typed direct engine Jul 14, 2026
@thymikee

Copy link
Copy Markdown
Member Author

CI blocks review on 7352d329:

  • Fallow reports new production-unused exports: MAESTRO_NEW_ISSUE_URL and pointFromPercentInFrame.
  • Provider integration and coverage fail broadly because the bundled Android snapshot-helper artifact is missing; Maestro, Android lifecycle, find, and related scenarios then fail through the real helper-required path.
  • iOS smoke fails testSinglePointerFlingFallsBackToXCTestCoordinateDragWhenPrivateSynthesisFails: all expected fallback metadata is nil, so the changed routing is not exercising the asserted fallback.

These are branch failures requiring owner action, not a readiness review. Please address them and update the head; review should resume only after the relevant checks pass.

@thymikee
thymikee force-pushed the feat/typed-maestro-engine branch from 3682be6 to 0bd3c01 Compare July 14, 2026 19:07
@thymikee

thymikee commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

Independent review pass at 0bd3c0186 (verified in-tree, not from commit messages)

Reconciled backlog — all items confirmed present

  • Percentage coordinates: integer-only regex with explicit "must be whole numbers" rejection (matches upstream, which throws on "50.5".toInt() rather than truncating input); Math.round/Math.trunc split in runtime-port-geometry.ts unified to truncation, matching upstream's integer division.
  • Target-swipe direction default: defaultDirection preset deleted (upstream YamlSwipeDeserializer requires direction; the driver-level default it previously cited is unreachable from YAML).
  • pasteText: de-advertised (removed from the parser map) rather than shipped with wrong semantics — upstream takes no argument and pastes the copyTextFrom/setClipboard clipboard.
  • ${...} interpolation: undefined and cyclic references now throw INVALID_ARGS. The single lenient path (resolveDeferred) is plan-build-time static decisions on runFlow.when.true, strictly re-resolved at execution — sound two-phase design, not a silent hole.
  • optional: one shared interpreter boundary, suppression gated on the dedicated isMaestroTestFailure classification (not the retryability set), cancellation checkpointed first, and the propagates AMBIGUOUS_MATCH from an optional target command regression test present. Matches the agreed policy: Maestro resolution keeps first-match/index semantics; ambiguity from nested public commands is never suppressible.
  • waitForAnimationToEnd: dedicated screenshot-stability operation whose captures bypass ordinary stabilization. Threshold units independently confirmed: upstream delegates to romankh3 image-comparison, whose getDifferencePercent is 100·Σdiff/(3·255·W·H) — a 0–100 scale — so the pinned 0.005 really is 0.005%, and the implementation replicates the upstream formula exactly. (An earlier audit gloss of "0.5%" was wrong.)
  • ADR 0015: now honestly describes the current pinned harness, specifies the three-layer oracle (JVM-generated semantic vectors included) as the accepted follow-up, and contains the ambiguity-placement and retry-clamp wording as agreed.

Size report (+22.6 kB unpacked): not dead weight

Rebuilt base and head and diffed all 132 output files:

  • No chunk duplication — the engine's distinctive strings each appear in exactly one chunk (session.js); the old engine is fully deleted from dist.
  • Conformance fixtures/scripts do not ship in the tarball.
  • session.js +22.7 kB tracks the engine's legitimate growth (measured: 5,595 → 6,782 production lines in src/compat/maestro, +21%): source provenance per IR node, observation-generation protocol, screenshot-stability op, retryIfNoChange, optional classification, structured failure evidence.
  • The new compatibility-policy.js chunk (9.7 kB) is mostly relocation: shared replay-variable/preset code now imported by both cli.js (export flow) and session.js; other chunks shrank correspondingly.
  • Wire cost: +4.5 kB gzip / +5.7 kB tarball; startup medians unchanged.

Why the engine is bigger than the old one (and why that isn't over-engineering)

The +21% splits into two roughly equal halves:

  1. Semantics the old engine didn't have, or had wrong. At main, waitForAnimationToEnd lowers to a positional string action into a generic runtime wait — no screenshot diff at all, so its core semantic was wrong. retryTapIfNoChange was explicitly rejected (there's a test asserting the rejection) even though upstream defaults it to true. optional was partial; atomic iOS selector dispatch, deferred gesture stabilization, wrapper-chain normalization, integer-percent validation, and ranked failure suggestions didn't exist.
  2. Explicitness replacing hidden state. The old engine was compact because it was implicit: string actions with positional payloads (['15000']), execution through the replay trampoline, compatibility state in three WeakMap caches, a second selector resolver. That compactness is where the bugs lived — ADR 0015's own complaint was that successful responses were hard to prove. The per-command allow-list parse layer and the evidence/generation protocol are bigger than what they replaced because they check what the old code assumed.

Calibration: the PR is net-negative in repo lines overall (−15.1k vs +14.5k — the deleted trampoline/caches and old tests offset the growth), and the wire cost is +4.5 kB gzip (+0.8%) since type declarations and validation messages compress to almost nothing.

The one structurally debatable piece is maintaining both the direct interpreter (engine*) and the compiled replay-plan representation (replay-plan-*, ~900 lines) — two execution surfaces for one language. That's the cost of replay resume/digest/progress integration, and the ADR's rejection of a shared replay VM until a second caller needs it is the right call; it's the first thing to revisit if native .ad replay ever grows structured control flow.

Pre-merge watch list (agrees with the "reviewable, not merge-ready" status)

  1. iOS wrapper-chain normalization before index is the riskiest semantic change in this push (alters selector matching) — the full 38-flow iOS run is the right evidence for it.
  2. tapOn retryIfNoChange final adjustment is unit-tested but not yet device-rerun.
  3. The two iOS runner lifecycle fixes (prewarm reset after URL open; target refresh after relaunch) reach outside compat/ — they need the non-Maestro gesture canaries the ADR performance contract already mandates, since they affect every iOS session.
  4. The horizontal-swipe preset deviation (avoiding iOS interactive-back) is a deliberate, documented parity break — the future app-observable oracle layer should encode it so it stays intentional.

Scope rule remains: new correctness defects in advertised surfaces block; everything else goes to the oracle backlog.

@thymikee
thymikee force-pushed the feat/typed-maestro-engine branch from d75911d to ead2022 Compare July 15, 2026 05:46
@thymikee

Copy link
Copy Markdown
Member Author

🔬 Thermo-nuclear code-quality review

Structure/maintainability pass (not a behavior/parity re-audit) over the whole diff, done with six focused reviewers over disjoint slices (parser · replay-plan · runtime-port · engine/target · daemon · export/oracle), with every lead finding independently re-verified against the tree at ead2022ed.

Verdict: the architecture is sound — this is not a redesign. The typed program → immutable plan → runtime-port split is a real improvement over the old trampoline, and several things I went in expecting to be messy are clean (see below). What remains is a set of high-conviction extract/delete moves — mostly duplication that has already drifted, plus a couple of drift-traps with behavior consequences.

What's genuinely good (worth protecting)

  • Decomposition respects the size budget. ~45 files in src/compat/maestro, none over 1k lines (largest export-flow.ts at 547). No file crossed the 1000-line line. Dispatch is properly table-driven (MAESTRO_RUNTIME_COMMAND_HANDLERS … satisfies), and the executeMaestroProgram/executeMaestroPlan facade is a clean 45 lines.
  • Type hygiene is excellent: 0 any, 0 @ts-ignore, only 21 thin as X casts and 1 as unknown across the whole module.
  • No feature-logic leak into shared daemon paths. Maestro sits behind exactly one seam — isTypedMaestroReplay() at session-replay-runtime.ts:158, which delegates and returns; the Maestro identifier appears in zero shared handlers. Where the generic and Maestro paths share code they do it through purpose-built seams, not if (isMaestro) branches. This is the thing I most expected to be wrong, and it isn't.
  • The replay-plan layer earns its keep — it does real compile-time work with no IR equivalent (runFlow include I/O in replay-plan-steps.ts:169, static when evaluation → omit/flatten/opaque, content digest for resume). It's not a pass-through remap.

Tier 1 — highest-value structural moves

T1 · runReplayScriptFile is a 260-line body with a suppressed complexity lint, not a decomposed orchestratorsession-replay-runtime.ts:138-398.
Line 138 is // fallow-ignore-next-line complexity. The team's own linter flagged this and it was silenced rather than split. Nearly every concern inside it is already a well-named top-level helper (preflightReplayAgainstActiveRepair, resolveReplayEntryIndex, createReplaySaveScriptArmer, …) — they're just hand-sequenced inline instead of composed by a smaller driver. Split into prepareReplayRun → executeReplaySteps → finalizeReplayRun; each becomes independently testable and none needs the suppression. A silenced complexity lint is an admission the decomposition is owed.

T2 · Opaque plan steps carry a dead, uncompiled IR subtree — wasted work + a latent landminereplay-plan-steps.ts:133/143/153, type replay-plan-types.ts:19-23.
For every repeat/retry/opaque runFlow, opaqueStep(plannedCommand, …) stores the raw command with its full commands[] subtree intact as step.command, and separately stores the compiled body. Execution only ever reads body + the scalar control fields (executeOpaqueStep, replay-plan-step-execution.ts:151-184; confirmed command.commands is read nowhere in the module). But executeOpaqueStep calls resolveCommand(step.command) (:155), and resolveValue (engine-flow.ts:152-160) deep-walks arrays/objects calling context.resolve() on every string — with failOnUnresolved=true (engine-context.ts:124). So the entire dead subtree is eagerly re-interpolated under the outer scope on every visit: not just wasted work, but a ${VAR} scoped to an inner body step would throw at the opaque boundary. It's also cloned, frozen, and hashed into the digest. Strip commands/include.commands before storing — let body be the sole representation of the nested sequence. This is the one place the plan layer should delete IR content, not carry it.

T3 · Delete parseScalarCommand — it's a second, hand-synced command dispatch tableprogram-ir-command-parser.ts:83-107.
The scalar switch hard-codes { kind, source } for the 7 no-arg commands, but every one of those commands' value-parsers already handles isNullNode(value) returning the identical result (verified: parseLaunchApp:162, parseEraseText:223, parseScroll, parseHideKeyboard, parseBack, parseWaitForAnimationToEnd:431, parseStopApp:450). Route bare scalars through parseCommandValue(node.value, null, node, context) and delete the whole function + its parallel table. Behavior-preserving for valid input; malformed bare commands even get a better domain error than the generic "not supported".

T4 · 75× hand-rolled ...(x === undefined ? {} : { x }) — reinvents an existing helper — 75 occurrences across the module (14 in program-ir-command-parser.ts, 13 in program-ir-gesture-parser.ts, 11 in runtime-port-commands.ts, …).
The repo already ships stripUndefined() (src/utils/parsing.ts:146) and compactRecord() (src/commands/command-input.ts:506) for exactly this, and they're used this way elsewhere (client-normalizers.ts, command-flags.ts). exactOptionalPropertyTypes is off, so there's no type-level excuse, and stripUndefined produces the same absent-key result the plan digest needs. Replace each builder with stripUndefined({ kind, source, a, b }) — the actual field list stops being buried in 3-token spreads. (This also dissolves the worst offender, the nested-ternary delay logic in tapOnInput, runtime-port-commands.ts:224-240.)


Tier 2 — duplication that has already drifted (extract one seam)

# Where What's duplicated Remedy
D1 runtime-targets.ts:52-72 & :107-120 & session-replay-maestro-failure.ts:231-247 The match→filter→normalize→select candidate pipeline is hand-assembled Export one rankMaestroCandidates(...); all three call it
D2 runtime-target-matching.ts:10-15 vs runtime-target-policy.ts:28-53 File named for matching is a 1-line filter wrapper; the real matchesMaestroTypedSelector predicate lives in policy.ts Merge matching.ts into policy.ts (4 target files → 3)
D3 runtime-targets.ts:96-126 iOS atomic-dispatch feasibility (countCanonicalDispatchCandidates) bolted into the general resolver every command pays for Move to the iOS tapOn path in runtime-port-commands.ts; the resolver shouldn't know this feature exists
D4 runtime-port-geometry.ts:93-94,153-154 vs contracts/scroll-gesture.ts:245-246 percent→pixel conversion duplicated and internally inconsistent: within screenSwipeEndpoints, horizontal swipes round (shared pointFromPercent, Math.round) while vertical swipes truncate (local pointInViewport, Math.trunc) One rounding rule for the layer — and it must be trunc (upstream Maestro integer-divides), so the horizontal→round path is the outlier to fix, not the direction the raw "route everything through pointFromPercent" instinct suggests. Behavior-affecting.
D5 daemon-runtime-port.ts:143-155 Settle-after-mutation contract is replicated per-closure and has drifted: doubleTapOn/longPressOn only invalidate(), skipping the requireStability()/settle every other mutating op does (tapOn, gesture, scroll, inputText) Drive settle from the single invokeMutation seam (it already knows it mutated) so all mutating ops settle by construction. If the omission is intentional it's undocumented. Behavior-affecting.
D6 daemon-runtime-port-observation.ts:326-333 vs runtime-port-observation.ts:38-41 visible/notVisible match predicate written verbatim twice Export/share conditionMatches
D7 daemon-runtime-port.ts:291-299 vs …-observation.ts sleepWithinBudget poll-until-deadline loop ; the hottest path (target resolution) is the one copy missing the cancellation normalization the others share Export sleepWithinBudget; the resolver polling loop uses it
D8 session-replay-divergence.ts:122-140 vs session-replay-target-verification.ts:138-157 resume + stampPendingRecordAndHealWatermark block copy-pasted verbatim (ADR-0012 invariant asserted twice) resolveReplayDivergenceResume({...}) called from both
D9 session-replay-divergence.ts:508-538 vs session-replay-maestro-failure.ts:192-227 suggestion rank/dedupe-by-basis algorithm duplicated generic vs maestro Generic rankAndDedupeByBasis<TBasis>(entries, rank)
D10 program-ir-gesture-parser.ts doubleTapOn (131-183) vs longPressOn (185-229) ~50-line "point-or-selector, conflict if both" parser near-verbatim (tapOn is a 3rd instance) Extract parsePointOrSelectorTarget(...)
D11 export-flow.ts:44-45 vs parser BASE_SELECTOR_KEYS/TAP_SELECTOR_KEYS Export re-encodes the Maestro selector vocabulary (TEXT_SELECTOR_KEYS/STATE_SELECTOR_KEYS) and launchApp option list independently; no round-trip test Share the key constants + add a test that re-parses export output through parseMaestroProgram

Tier 3 — smaller type / dead-surface cleanups

  • Vacuous Extract<…, { kind: string }> + dead runtime guardreplay-plan-steps.ts:236-251. commandStep's param type matches everything, so an AppError throw (the file's only use of AppError) simulates the narrowing at runtime. engine-types.ts already has MaestroRuntimeCommand; type the param as that, delete the throw + the now-unused import.
  • runtime-port.ts is a shipped file that only tests usecreateMaestroRuntimePort has zero production importers (only runtime-port.test.ts); prod goes through createDaemonMaestroRuntimePort. Delete it; build the tiny {execute, observe} inline in the test fixture.
  • Dead tapOn operation fieldslabel/index/childOf are threaded through tapOnInput (runtime-port-commands.ts:224-240) but consumed during target resolution and never read by the impl. Drop from the operation input type.
  • Dead, name-colliding typeReplayVerifiedTargetGuard in session-replay-target-classification.ts:388-401 is never imported and has a different shape than the same-named type actually used in session-replay-target-verification.ts:62-65. Delete.
  • Unsafe cast across a boundary that has a real typesession-replay-runtime.ts:579-605 reads ReplayDivergenceResume back as Record<string, unknown> via two as casts to set repairSessionHeld. One typed accessor instead.
  • Identity/pass-through indirection: parseMaestroSelectorEntries wrapper (program-ir-gesture-parser.ts:69); MaestroEngineExecutionOptions = MaestroEngineOptions alias (engine-types.ts:118); resolveMaestroTimingPolicy lives in the engine-flow.ts grab-bag but validates data in compatibility-policy.ts; sortKeysDeep duplicated between replay-plan-digest.ts and replay/plan-digest.ts; the two near-identical keyboard/alertRefFrameEffect resolvers in registry.ts could share a factory.

Two codebase-health / scope notes (not line edits)

  • The conformance "oracle" doesn't run. scripts/maestro-conformance.test.ts uses node:test, isn't matched by any vitest project (vitest.config.ts unit-core whitelists src/**/*.test.ts + one named file), and maestro:conformance is referenced by no CI workflow or check aggregate. The "upstream" fixtures are hand-typed JSON with sha256 provenance fields that nothing fetches or verifies, covering ~4 of ~20 command kinds — a passing run proves self-consistency of hand-written JSON, not Maestro conformance. I know the ADR already frames the real 3-layer oracle as a follow-up; the ask here is narrower: an unrun suite implies a guarantee it doesn't provide — either wire maestro:conformance into CI, or reframe/mark the checked-in harness as an agent-device parser regression fixture (drop the "upstream"/sha256 framing) so nobody reads it as active conformance coverage.
  • Scope hygiene: the registry.ts +117 (and the client-types.ts refsGeneration) is ADR-0014 ref-frame-effect wiring with zero Maestro content — an unrelated cross-cutting feature riding in this branch. Splitting it out would let it be reviewed against its actual purpose.

None of the above is a redesign. T1–T4 plus the D-series extractions delete real complexity and drift-traps; treat D4 (geometry rounding) and D5 (settle drift) as correctness rather than cosmetics, since both change device behavior.

@thymikee
thymikee force-pushed the feat/typed-maestro-engine branch from ead2022 to 13bddfa Compare July 15, 2026 13:31
@thymikee

Copy link
Copy Markdown
Member Author

Current head 13bddfab55aefa5e1c8b3f87f3e55e35b21fb71a is still CI-blocked on two actionable failures:

  • Fallow Code Quality: src/compat/maestro/engine.ts:16 exports executeMaestroProgram, but production-unused-exports reports no consumer. Remove the export or wire the intended production consumer.
  • Coverage: three tests in src/platforms/android/__tests__/input-actions.test.ts fail because text verification reaches captureAndroidUiHierarchy without the bundled Android helper artifact (Android snapshot helper is unavailable: the bundled helper artifact was not found). The tests/fixture need to provide the helper path or mock the helper-backed snapshot boundary consistently.

The remaining completed checks were green when inspected; Linux and iOS smoke were still running.

@thymikee

Copy link
Copy Markdown
Member Author

Rechecked new head 8d0e28decfa5b29fda5476e865933e536bb6eb6a: the two previously reported CI blockers are unchanged. Fallow still reports executeMaestroProgram as a production-unused export, and Coverage still has the same three input-actions.test.ts failures because the bundled Android snapshot helper artifact is unavailable. Please address both before another readiness review.

@thymikee

Copy link
Copy Markdown
Member Author

New blocker at head 05dfe06e33a0eb4f3368a7fd2b5914518e4f21f1 despite green CI:

rankMaestroCandidates scopes visible/ranked through childOf, but returns the raw pre-scope matches (src/compat/maestro/runtime-target-ranking.ts:29-40). resolveMaestroTargetFromSnapshot then uses that raw set for evidence and failure selection (runtime-targets.ts:58-62). When the parent exists but matching children exist only outside it, the result incorrectly reports matched: true, a nonzero candidate count, and “matched N element(s), but none were visible” instead of a scoped miss. The previous implementation replaced matches with scoped.matches.

Please return/use the scoped matches for evidence/failure semantics and add a regression for: parent exists, child selector matches only outside that parent. No branch changes were made.

@thymikee
thymikee merged commit 37895ca into main Jul 15, 2026
23 checks passed
@thymikee
thymikee deleted the feat/typed-maestro-engine branch July 15, 2026 19:26
thymikee added a commit that referenced this pull request Jul 15, 2026
… helper

Rebase-conflict resolution against origin/main. #1217 (typed direct Maestro
engine) extracted `buildReplayDivergenceSuggestionForNode` out of
`resolveSuggestionCandidate` and added a second caller in
`session-replay-maestro-failure.ts`. My #1269 change had added `nodes` to
the `buildSelectorChainForNode` call that #1217 moved into the extracted
helper, so after rebase the helper referenced an out-of-scope `nodes`.

Thread the record-time tree as a required `nodes` param on the helper and
pass it from BOTH callers (each already has it in scope). This keeps the
non-unique-id demotion applied wherever a divergence/repair suggestion
chain is built — now including the typed-Maestro suggestion path — with no
behavior change for the already-unique-id case.
thymikee added a commit that referenced this pull request Jul 16, 2026
#1272)

* fix(replay): demote non-unique ids from writer identity/selector chain (#1269)

Android list-row GET replays bind the wrong row because the recorder uses
the non-unique framework resource id `android:id/title` (matchCount 11 on
Settings root) as primary identity; positional drift then makes the
identity verifier correctly refuse with `identity-mismatch`.

Demote an id from identity whenever it matches more than one node in the
record-time tree (capture-time uniqueness, not an `android:id/*`
namespace check — a reused RN FlatList testID hits the same class on
iOS). Applied in both places a recorded id feeds identity:

- `computeTargetEvidence` (session-target-evidence.ts): the `target-v1`
  identity tuple falls back to role+label when the id's own capture-time
  match count exceeds one, reusing the existing `filterIdentitySet`
  domain machinery (an empty ancestry degrades it to a plain id scan).
- `buildSelectorChainForNode` (selectors/build.ts): the recorded selector
  chain omits a non-unique id rather than leading with it. Every writer
  call site (get/press/fill recording, plus the divergence-suggestion
  path) now passes the record-time tree so the check has something to
  count against; omitting it preserves prior behavior for isolated-node
  callers (tests).

Resolver-side `resolveSelectorChain` and live press/fill resolution are
untouched per ADR 0012 (disclosed-not-changed disambiguation) — this is
writer/replay-scoped only.

Amends ADR 0012 decision 3: an id may serve as identity (and lead the
selector chain) only when it uniquely denotes the target in the
record-time tree.

Adds fixtures: an Android duplicated-`android:id/title` list (the
measured repro) and an iOS/RN duplicated-testID FlatList shape, both
demoted and still verifying via the now-selective label; a regression
case confirming an already-unique id is unaffected.

Out of scope: the Android list-*press* class (matchCount 12, label-less
`role="linearlayout"` container with no id at all to demote) needs a
separate design decision — deriving identity from the labeled
descendant. Tracked as a follow-up, not attempted here.

* fix(replay): unify the id-uniqueness predicate across both writer sites (#1269 review)

Address the maintainer review on #1272:

1. ONE shared uniqueness predicate. The two demotion sites were counting
   id matches with DIFFERENT semantics — `demoteNonUniqueId` via
   `filterIdentitySet` (NFC + 256-byte cap, and a broken-parent-walk
   exclusion), `selectableId` via a raw `normalizeSelectorText` scan (trim,
   no NFC/cap, no exclusion) — so the identity tuple and the selector chain
   could disagree and half-demote (id gone from one, kept in the other).
   Extract `idMatchCountInTree(nodes, id)` in target-identity-node.ts,
   counting over the canonical `readNodeLocalIdentity` id the replay
   verifier keys on, with no ancestry/parent-walk exclusion. Both
   `demoteNonUniqueId` and `selectableId` now call it. Corrects the
   inaccurate "vacuously-true / plain id scan" comment.

   Cross-invariant test (build.test.ts): for the same node+tree,
   evidence.id === undefined iff the built chain has no id= clause — across
   demoted, unique, and a non-NFC (decomposed vs precomposed) edge case.
   Verified it fails under the old raw-scan and passes under the unified
   predicate.

2. End-to-end reorder proof (session-replay-target-classification.test.ts):
   record against a tree whose rows share android:id/title, then classify
   against a DIFFERENT replay tree where the shared-id rows reorder — the
   demoted role+label identity rebinds the correct row (verified,
   matchCount 1) while `id="android:id/title"` resolves ambiguously (null).
   This pins the FDR 1.0 -> 0 mechanism, not just record-time demotion.

3. Removed the conflated "20/20 clean" live-number comment from the unit
   test; it now states the mechanism (role+label selectivity) instead.

Behavior for the already-clean unique-id path is unchanged: for ordinary
ascii ids the canonical count equals the old raw count. The only outcomes
that change are the edge cases the old split mishandled (non-NFC, broken
parent walk) — where demotion is the correct result. The kept clause still
emits the chain's own normalizeSelectorText id string, so unique ids lead
the chain exactly as before.

* fix(replay): thread record-time tree through the extracted suggestion helper

Rebase-conflict resolution against origin/main. #1217 (typed direct Maestro
engine) extracted `buildReplayDivergenceSuggestionForNode` out of
`resolveSuggestionCandidate` and added a second caller in
`session-replay-maestro-failure.ts`. My #1269 change had added `nodes` to
the `buildSelectorChainForNode` call that #1217 moved into the extracted
helper, so after rebase the helper referenced an out-of-scope `nodes`.

Thread the record-time tree as a required `nodes` param on the helper and
pass it from BOTH callers (each already has it in scope). This keeps the
non-unique-id demotion applied wherever a divergence/repair suggestion
chain is built — now including the typed-Maestro suggestion path — with no
behavior change for the already-unique-id case.
thymikee added a commit that referenced this pull request Jul 16, 2026
#1284)

#1284 kept the hard-fail from #1217 (a silent stock-UIAutomator fallback
produced a materially different, app-window-only capture) but asked for
actionable hints on both failure modes:

- Artifact missing on disk: hint now names the exact `pnpm build:android`
  command, the full dist file set it produces, and notes packaged installs
  ship it via prepack.
- Artifact present but the device rejects the install (adb/OEM policy):
  the underlying adb error already surfaces in the message; the hint now
  explicitly frames this as a device-side failure distinct from a missing
  build artifact, tagged via a new androidSnapshotHelperInstallFailure
  detail set at the adb install call site.
thymikee added a commit that referenced this pull request Jul 16, 2026
#1284) (#1285)

* fix(android): actionable error when the snapshot helper is unavailable (#1284)

#1284 kept the hard-fail from #1217 (a silent stock-UIAutomator fallback
produced a materially different, app-window-only capture) but asked for
actionable hints on both failure modes:

- Artifact missing on disk: hint now names the exact `pnpm build:android`
  command, the full dist file set it produces, and notes packaged installs
  ship it via prepack.
- Artifact present but the device rejects the install (adb/OEM policy):
  the underlying adb error already surfaces in the message; the hint now
  explicitly frames this as a device-side failure distinct from a missing
  build artifact, tagged via a new androidSnapshotHelperInstallFailure
  detail set at the adb install call site.

* fix(android): correct helper-missing hint to the two runtime-required files

Review correction on #1285: resolveAndroidSnapshotHelperArtifact only
fs.access'es the versioned .manifest.json and the .apk it references —
the sha256 is a manifest field, and *.idsig is excluded from the npm
package by design. The hint no longer claims the full sidecar set is
required, and the test now rejects any future idsig claim.

* fix(android): cover install rejections and preserve diagnostic identity (#1285 review)

P1: the device-side install marker now covers the whole install phase.
ensureAndroidSnapshotHelper previously tagged only a resolved nonzero
install result; an AndroidAdbProvider.install rejection (enriched
INSTALL_FAILED_* AppError from the provider funnel) bypassed the marker
and fell back to the generic retry/doctor hint. Both paths now flow
through markAndroidSnapshotHelperInstallFailure, which mutates details
in place so the original code, message, hint, details, and cause all
survive. Regression covers the public daemon snapshot route with a real
request handler and an injected provider whose install rejects.

P2: androidSnapshotHelperCaptureError rewrapped through normalizeError,
which lifts diagnosticId/logPath out of details — the rewrap dropped
them (ADR 0010 violation). They are now reinstated into the rewrapped
error's details. Hint selection moved to a helper to keep the function
under the complexity gate.

* fix(android): restore all lifted wire fields through the capture rewrap (#1285 review)

normalizeError hoists hint, diagnosticId, logPath, retriable, and
supportedOn out of details (ADR 0010); the capture rewrap restored only
diagnosticId/logPath, so a transient-classified install rejection (e.g.
connection_dropped) lost its structured retriable signal on the public
daemon error. liftedDiagnosticIdentity is generalized to liftedWireFields
covering the complete hoisted set (hint stays owned by the capture hint
selector). Public route regression: a retriable provider install
rejection keeps error.retriable === true on the daemon snapshot response.
thymikee added a commit that referenced this pull request Jul 16, 2026
…ance oracle (#1289)

* test: replace the hand-typed Maestro fixture with a generated conformance oracle

Closes #1274.

The old harness (scripts/maestro-conformance*) compared 5 hand-authored flows
against a hand-typed transcription of Maestro 2.5.1's command model. It proved
parser self-consistency, not conformance: all four bug classes that cost #1217
days of live debugging slipped past it by construction, and it verified no
upstream SHAs despite parsing them.

Every expected value here is generated from the pinned upstream artifacts.
dev.mobile:maestro-orchestra:2.5.1 is published on Maven Central, so the harness
runs the real parser and reads the real bytecode — no full Maestro source build.

Layer 1 (parser): a Gradle/Kotlin harness drives the pinned YamlCommandReader
over a corpus of 42 vendored maestro-test flows (sha256-recorded) plus authored
bug-class, coverage, and invalid flows, capturing each parse. The verifier parses
each flow with the live engine and classifies it identical / both-reject /
we-reject / mismatch / we-are-lenient. Every non-identical outcome must be a
declared divergence, so the 17 we-reject entries in expected-divergence.ts are
the mechanical parity backlog (assertTrue, clipboard, travel, killApp, and
option-level gaps) rather than silent drift.

Layer 2 (semantics): ASM reads static-final constants straight from the pinned
bytecode without initializing driver classes (MAX_RETRIES_ALLOWED=3,
SCREENSHOT_DIFF_THRESHOLD=0.005, ANIMATION_TIMEOUT_MS=15000, erase cap, and the
iOS pre-tap gate we intentionally omit), plus the parser-observed 400ms swipe
default. Each is cross-checked against MAESTRO_COMPATIBILITY_PRESETS.

Layer 3 (differential): scheduled device scenarios. Cross-engine comparison is
outcome parity only and says so; finer behavior is asserted engine-side via
invariants over replay-timing.ndjson. Bug class 4's detector — a tap must not
consume the whole settle budget, since a full-budget tap means the stability loop
never latched while the flow still passes — is pure and unit-tested against
synthetic traces; only the device run is scheduled-only.

regenerate.mjs verifies the pinned jar SHA-256s before trusting output and is
byte-deterministic across runs. Layers 1-2 verify in normal CI via node --test
with no Java (the job installs deps: unlike the layering guard it copies, the
verifier parses with the live engine, which imports the `yaml` package).

Acceptance: the four bug classes each have a fixture; every command in
SUPPORTED_MAESTRO_COMMAND_NAMES (the parser's own dispatch table, now exported as
the single source of truth) is corpus-covered or listed unverified; the five
documented deviations are expected-divergence entries.

* fix: address review findings on the conformance oracle

P1 — layer-3 scenarios could never run. They pointed at layer-1 corpus flows,
which exist only to be PARSED: they name a fictional com.example.app and elements
that exist on no device. A device run would have failed before exercising any
runtime behavior, making bug class 4's detector silently vacuous. Layer 3 now has
its own flows under differential/flows/ driving the real fixture app
(examples/test-app, com.callstack.agentdevicelab); the workflow builds and
installs it and hard-fails if it is missing. A test enforces the separation so a
scenario can never point back at the parse corpus.

Nothing else in this repo builds or installs the Expo fixture app, so those steps
are new and unproven. The workflow is therefore dispatch-only: the cron is removed
until a supervised first run proves the path. A nightly job that fails at 05:00
every day teaches nothing.

P2 — layer 3 installed whatever version the online installer served. It now pins
MAESTRO_VERSION from pinned-upstream.json, so layer 3 cannot drift from the
version layers 1-2 claim, and asserts `maestro --version` matches.

P2 — fixture content was not bound to regeneration. CI compared only the embedded
upstream metadata, so a hand edit to a captured command or constant passed: the
transcription failure mode this oracle exists to remove. Two-layer fix, because
per-PR CI must stay Java-free and cannot re-derive:
  - Each fixture now carries a contentHash seal that the verifier recomputes, so
    editing a capture breaks the build. Tamper-evident, and tested by actually
    tampering rather than assuming a hash comparison works.
  - New scheduled conformance-regenerate job re-runs the harness against the
    pinned jars and fails on any byte difference. Forgery cannot survive a real
    re-derivation. This is what makes "generated from upstream" enforced.

P3 — boot-ios-test-simulator requires runtime-version; now passed alongside
preferred-device-name, as the other iOS workflows do.

* tmp: trigger layer-3 differential on this branch to prove the device path

workflow_dispatch cannot run pre-merge (it registers from the default branch), so
this temporary push trigger exists only to execute the never-run device path on
the PR head and capture evidence. Removed before merge.

* fix(ci): install the fixture app unfrozen for the layer-3 device run

First live run of the device path failed at the very first step:
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. CI implies --frozen-lockfile and the fixture
app's lockfile is out of sync with its package.json overrides. No CI job has ever
built examples/test-app, so that drift was never surfaced.

* fix: drop --ignore-workspace from test-app:install (defeats #649 security overrides)

The first live run of the layer-3 device path failed at
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH, and the cause is a real latent bug rather
than a stale lockfile.

#649 moved the fixture app's `overrides` into examples/test-app/pnpm-workspace.yaml
precisely because pnpm only honors overrides from a workspace root — they pin
transitive deps (ws, brace-expansion, xmldom, postcss, uuid, shell-quote) to
versions that clear Dependabot alerts. But `test-app:install` passes
--ignore-workspace, which ignores that very file, so the overrides are dropped
and no longer match the lockfile that has them baked in. It goes unnoticed
locally because interactive installs are not frozen, and no CI job has ever
installed this app.

Dropping --ignore-workspace makes examples/test-app resolve as its own workspace
root (it has its own pnpm-workspace.yaml and is not a member of the repo-root
workspace), so the overrides apply and a frozen install succeeds. Verified both
directions locally: with the flag + --frozen-lockfile reproduces the CI failure;
without it, a frozen install completes and the lockfile's overrides stay intact.

Note the workaround this replaces would have been actively harmful: installing
with --no-frozen-lockfile resolves the mismatch by regenerating the lockfile
WITHOUT the overrides, silently reverting the app to the vulnerable transitive
versions #649 pinned away.

* fix: make layer-3 scenarios prove what they claim, and parse the Maestro version

Run 3 (29497919702) got the whole device path working: Expo build (30m), app
installed, simctl check, pinned Maestro CLI install. Only the version ASSERTION
failed — `maestro --version` prints an analytics banner before the version, and
`tr -d '[:space:]'` mashed banner+version into one string. The CLI was correctly
2.5.1. Match the semver line instead, and set MAESTRO_CLI_NO_ANALYTICS (CI should
not phone home). Verified the parse against the exact CI output: banner and clean
forms both yield 2.5.1, wrong/empty still fail.

tap-retry-if-no-change was vacuous: it tapped a navigating control, so the first
tap always succeeded and retryIfNoChange never ran — it passed while proving
nothing. It now taps the app's non-interactive title so the screen cannot change
and the retry path is forced, and asserts tapRetries >= 1 from the trace
(MaestroRuntimeMetrics already records it per step). A new metricAtLeast invariant
kind carries the assertion; a test reproduces the old vacuity.

percent-swipe no longer claims bug class 1. Truncation vs rounding is a <=1px
delta that no app-observable device outcome can distinguish, so pass/pass could
never back that claim up. The runtime half is instead pinned exactly by a pure
unit test of resolveMaestroCoordinate (it short-circuits on a known viewport, so
no device is needed) — verified to catch the regression by flipping trunc->round,
which turns 3 of 6 tests red. Truncation had no test coverage at all before this.
A test now forbids any device scenario from re-claiming bug class 1.

* fix(ci): pass --maestro and match the fixture app's real UI in layer-3 flows

Run 4 (29500262301) reached the differential itself — build, install, simctl
check and the pinned Maestro 2.5.1 verification all passed — and surfaced two
real bugs, both mine:

1. The runner invoked `agent-device test <flow>` without --maestro, so every
   scenario failed with "test does not support this file type". The repo's own
   scripts/run-test-app-maestro-suite.mjs passes it; the flag is what routes a
   .yaml through the Maestro compat engine.

2. settle-after-tap and percent-swipe assumed home-open-form is on screen at
   launch. It is not: real Maestro reported "Element not found: home-open-form",
   and the app's own helper flow scrolls it into view first. settle-after-tap now
   scrolls before tapping, mirroring that helper; percent-swipe no longer
   navigates at all and swipes the scrollable home screen, so it tests the
   conversion and nothing else.

The remaining two flows already reported maestro=pass, so only the agent-device
invocation was wrong for those. Note the settle invariant correctly reported
"no-data: no completed tapOn steps" and FAILED rather than passing — a detector
that cannot run is a failure, as intended.

* feat: declare layer-3 divergences and schedule the differential

Layer 3 ran both engines for the first time (29504440599) and immediately found a
real engine bug. Blocking the measurement instrument on repairing what it just
measured inverts the dependency, so layer 3 now gets the contract layer 1 already
had: every divergence is a decision on the record.

Adds `knownDivergence: { reason, tracking }` to the scenario type — the layer-3
twin of FLOW_DIVERGENCES. A declared divergence keeps the run green; only
UNDECLARED ones fail. Two rules stop that from rotting, both enforced
mechanically rather than by prose discipline:

  - `tracking` is required and must be a real issue URL (run.test.ts), because a
    declaration with nothing behind it is how "temporarily expected" becomes
    permanent without anyone deciding to.
  - a stale declaration FAILS: if a declared-divergent scenario starts passing,
    the run goes red until the declaration is removed. The fix PR must delete it,
    and the differential then enforces the gap stays closed — the oracle is the
    acceptance test for its own findings.

Declared:
  - settle-after-tap  -> #1299. Our scrollUntilVisible times out finding
    home-open-form where Maestro 2.5.1 scrolls to it and passes. Real engine
    correctness bug in an advertised command, found by this differential. Blocks
    bug class 4's device detector until fixed.
  - tap-retry-if-no-change -> #1300. The invariant caught the scenario being
    vacuous: both engines pass but tapRetries was 0, so retryIfNoChange never
    ran. Needs an inert fixture control; a scenario defect, not an engine one.

Proven green on both engines and enforced now: percent-swipe,
optional-warned-not-failed — the latter is real device-verified warned-vs-failed
parity.

With declarations in place the differential is green, so the schedule goes in
(cron 05:00) per #1274. A green run still prints what it is not proving.

* fix: park the flaky retry scenario instead of declaring it a divergence

Run 29510020718 fired the stale-declaration guard on its first outing and caught
my own mistake. tap-retry-if-no-change measured tapRetries=0 in run
29504440599 and tapRetries=1 in 29510020718 — same flow, same commit. So it is
not vacuous as #1300 originally claimed: it is NON-DETERMINISTIC. The tap
sometimes holds the hierarchy signature still and sometimes does not, because the
fixture home screen carries live content.

That exposes a real limit of the mechanism added in the previous commit:
knownDivergence assumes the divergence REPRODUCES. A declared-but-flaky scenario
flips between known-divergence (green) and stale-declaration (red) at random — a
coin-flip scheduled job, which is worse than no scenario because it teaches
people to ignore the differential.

So the scenario is parked, not declared. The flow and the tapRetries invariant
stay implemented and unit-tested, so the fix PR only re-adds the scenario once
the fixture has an inert control. retryIfNoChange therefore has NO device
coverage right now — tracked in #1300 and stated plainly rather than disguised by
a green run. A test keeps it out of the active set until then.

#1300 updated with the corrected diagnosis and both runs' evidence.

Active differential: settle-after-tap (declared divergence, #1299), percent-swipe
and optional-warned-not-failed (both enforced, pass/pass on real devices).

* fix: make a knownDivergence waiver cover exactly one failure, not any failure

P1 from re-review, and a real flaw: the code did not do what its own comment
claimed. runScenario() collapsed every unexpected outcome and every invariant
failure into `misbehaved`, then turned ANY of them green if the scenario carried
a declaration. So while the #1299 scrollUntilVisible waiver is open, upstream
Maestro could start failing too — or a different invariant could break — and the
scheduled job would still report known-divergence and pass. A waiver for one bug
was silently amnesty for the next. That is the exact failure this oracle exists
to prevent, committed one commit after building the guard against it.

knownDivergence now requires an `expected` signature: both engines' outcomes plus
each declared invariant's status. The runner matches it exactly —
  - matches            -> known-divergence (green, tracked)
  - misbehaves differently -> failed (red): not the failure the waiver covers
  - stops misbehaving  -> stale-declaration (red): remove the declaration
#1299's signature pins what runs 29504440599/29510020718 actually observed:
maestro=pass, agent-device=fail, settle invariant no-data.

Tests prove unrelated failures stay red under an open waiver: upstream also
failing, our engine unexpectedly passing, a different invariant status, and a new
invariant appearing are each NOT covered. A signature where both engines pass is
rejected outright as describing no divergence.

Also retains replay-timing.ndjson as a run artifact (review evidence note): the
invariants are computed from that trace, so a report saying "tapRetries was 0"
cannot be audited once the runner is gone without it.

* perf(ci): cache the fixture app build for the layer-3 differential

The differential job took ~30 minutes, of which 1331s (22 min, 79%) was building
the Expo fixture app and only 347s was the differential itself — rebuilt from
scratch on every run for an app that changes almost never.

Cache the built .app, keyed on everything that can change the binary: the app's
sources, native config, dependency graph, the build step itself, the iOS runtime,
and the Xcode version. Mirrors the existing setup-apple-replay prebuilt-runner
cache (same action pin, same Xcode-key + source-hash shape).

On a hit the build is skipped entirely and the bundle is installed straight onto
the booted simulator (~seconds), taking the job to roughly 8 minutes. On a miss
it falls back to exactly the previous behaviour and repopulates, so the worst
case is unchanged. The existing simctl verification still gates both paths, so a
bad cache cannot produce a vacuous green: if the app is not installed, the job
fails loudly rather than running scenarios against nothing.

Note the first run after this lands is necessarily a miss.

* refactor(ci): extract setup-fixture-app so any job can use the cached app

The fixture-app build + cache was inline in the differential workflow, so nothing
else could reach it. Extracted to a composite action mirroring
setup-apple-replay, because the capability is what #320 has been missing: it
wants replay coverage moved off Apple system apps onto a controlled fixture with
stable ids, and that fixture (examples/test-app) already exists — CI just had no
way to build and install it.

The cache is genuinely shared. GitHub caches are per-repository and readable
across workflows, and a run restores from its own branch or the default branch,
so once a run on main populates it every workflow gets the hit and only the first
one pays the ~22 minutes. The key is computed inside the action from a fixed
input list and deliberately contains nothing caller-specific — folding a caller's
workflow path into it would silently unshare the cache.

Also removes a duplication risk: the action reads the bundle id from the built
app's Info.plist rather than hardcoding it, so it cannot drift from what was
actually built, and it fails loudly if the app is not installed. The conformance
workflow keeps its own narrower assertion — that the installed id is the one its
scenarios target — since that is its concern, not the action's.

Usage:
  - uses: ./.github/actions/setup-fixture-app
    with:
      runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
  # outputs: app-path, app-id, cache-hit

* chore(ci): remove the temporary branch push trigger

Run 29519848340 on this head executed both engines against the real fixture app
and came back green, so the trigger that existed only to prove the never-run
device path has done its job.

Merged config is now cron (05:00) + workflow_dispatch, as required by #1274.

  known-divergence  settle-after-tap  maestro=pass agent-device=fail (#1299)
  ok                percent-swipe              maestro=pass agent-device=pass
  ok                optional-warned-not-failed maestro=pass agent-device=pass

This commit will not itself trigger a run: GitHub evaluates triggers at the
pushed commit, and the push trigger is gone in it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant