Skip to content

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

Merged
thymikee merged 13 commits into
mainfrom
claude/maestro-conformance-oracle-acf8d5
Jul 16, 2026
Merged

test: replace the hand-typed Maestro fixture with a generated conformance oracle#1289
thymikee merged 13 commits into
mainfrom
claude/maestro-conformance-oracle-acf8d5

Conversation

@thymikee

Copy link
Copy Markdown
Member

Closes #1274.

Why

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 parsed upstream SHAs without ever verifying them.

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

The three layers

Layer Proves Generated by Runs in
1 — parser Our parser accepts/rejects/normalizes each flow like upstream pinned YamlCommandReader over the corpus node --test, per-PR
2 — semantics Our retry/geometry/timing constants match upstream ASM-read bytecode constants + parser-observed defaults node --test, per-PR
3 — differential Outcome parity on a device + engine-side timing invariants real Maestro vs agent-device test scheduled

Layer 1 drives the pinned parser over 42 vendored maestro-test flows (sha256-recorded provenance) plus authored bug-class, coverage, and invalid flows. The verifier parses each with the live engine and classifies it identical / both-reject / we-reject / mismatch / we-are-lenient. Any non-identical outcome must be a declared divergence, so expected-divergence.ts is the mechanical parity backlog (17 entries) instead of silent drift.

Layer 2 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 cross-checked against MAESTRO_COMPATIBILITY_PRESETS.

Layer 3 is honest about what it proves: cross-engine comparison is outcome parity only, and the scenario fields say so. Finer behavior is asserted engine-side via invariants over replay-timing.ndjson.

Current state: 35 identical / 7 both-reject / 17 we-reject / 0 mismatch / 0 coverage gaps.

Acceptance (#1274)

  • Four bug classes each have a fixture. 1 (percent) and 2 (target-swipe direction) are clean both-reject at parse. 3 (retry cap) is the layer-2 MAX_RETRIES_ALLOWED cross-check. 4 (settle ordering) has no reflectable constant, so layer 3 is its only home — its detector is "a tap must not consume the entire settle budget" (a ~2093ms tap against 2000ms means the loop never latched while the flow still passes, which outcome parity cannot see). The evaluator is pure and unit-tested against synthetic traces; only the device run is scheduled-only.
  • Every command corpus-covered or listed unverified, against SUPPORTED_MAESTRO_COMMAND_NAMES — now exported from the parser's own dispatch table as the single source of truth.
  • Documented deviations are expected-divergence entries: hierarchy-vs-screenshot animation wait, horizontal-swipe presets, condition-poll-as-stabilization, ambiguity strictness under optional, and the omitted iOS 3s pre-tap gate.

Wiring

  • regenerate.mjs verifies the pinned jar SHA-256s before trusting output (the gate the old harness faked), and is byte-deterministic across runs. Manual, pin-bump only.
  • New maestro-conformance CI job. Note it installs deps, unlike the layering guard it otherwise mirrors: the verifier parses with the live engine, which imports the yaml package.
  • Layer 3 runs on the scheduled conformance-differential workflow.
  • Gradle wrapper pinned with distributionSha256Sum — this tool refuses unverified upstream jars, so its own toolchain gets the same treatment.

Things worth a reviewer's eye

  • The corpus surfaced two genuine upstream facts. Decimal percentages in swipe endpoints are parse-rejected upstream (unlike tapOn points, which reject at runtime). And upstream's SnakeYAML accepts duplicate mapping keys (last-wins) where we reject — declared as a deliberate stricter-than-upstream divergence.
  • Layer 3's device path has never executed (scheduled-only by construction). The invariant logic is unit-tested; the end-to-end device wiring is not.
  • The 17 we-reject entries are a backlog, not a regression — each names the unsupported command/option.

Verification

typecheck (0 errors) · oxlint · oxfmt · layering guard · both fallow checks · 161 maestro unit tests · 23 conformance tests · fixtures byte-identical across regenerations.

…ance 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.
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-16 18:22 UTC

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.7 MB 1.7 MB -725 B
JS gzip 557.5 kB 557.5 kB +25 B
npm tarball 673.2 kB 673.1 kB -93 B
npm unpacked 2.4 MB 2.4 MB -313 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.7 ms 27.0 ms +0.3 ms
CLI --help 56.3 ms 59.1 ms +2.8 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/gestures.js +22.5 kB +8.1 kB
dist/src/tv-remote.js -13.3 kB -4.6 kB
dist/src/session.js -68 B -44 B
dist/src/cli.js -103 B -37 B
dist/src/runtime.js -42 B -23 B

@thymikee

Copy link
Copy Markdown
Member Author

Current head is not merge-ready because the scheduled layer-3 path cannot currently prove the acceptance claim:\n\n- P1: the differential workflow boots a clean simulator but never installs a target app; the scenarios target and elements such as . It will fail before exercising settle ordering, which is bug class 4's only non-vacuous detector. Install/configure a fixture app with the required surface.\n- P2: layer 3 installs whatever Maestro version the online installer serves, while layers 1-2 and the oracle claim pinned Maestro 2.5.1 behavior. Pin and verify the layer-3 Maestro CLI/artifact too.\n- P3: requires , but this workflow omits it despite declaring . Pass the input as existing workflows do.\n- P2: normal CI trusts the checked-in generated fixture JSON and only compares its embedded upstream metadata with ; it does not bind fixture content to regeneration output. A hand edit to fixture commands/constants can therefore still pass. Add a deterministic provenance/hash check (or equivalent regeneration verification) so "generated from upstream" is enforced, not just documented.\n\nThe per-PR checks are green, but they do not execute layer 3.

@thymikee

thymikee commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Current head is not merge-ready because the scheduled layer-3 path cannot currently prove the acceptance claim:

  • P1: the differential workflow boots a clean simulator but never installs a target app; the scenarios target com.example.app and elements such as Submit. It will fail before exercising settle ordering, which is bug class 4's only non-vacuous detector. Install/configure a fixture app with the required surface.
  • P2: layer 3 installs whatever Maestro version the online installer serves, while layers 1-2 and the oracle claim pinned Maestro 2.5.1 behavior. Pin and verify the layer-3 Maestro CLI/artifact too.
  • P3: boot-ios-test-simulator requires runtime-version, but this workflow omits it despite declaring IOS_RUNTIME_VERSION. Pass the input as existing workflows do.
  • P2: normal CI trusts the checked-in generated fixture JSON and only compares its embedded upstream metadata with pinned-upstream.json; it does not bind fixture content to regeneration output. A hand edit to fixture commands/constants can therefore still pass. Add a deterministic provenance/hash check (or equivalent regeneration verification) so "generated from upstream" is enforced, not just documented.

The per-PR checks are green, but they do not execute layer 3.

@thymikee

Copy link
Copy Markdown
Member Author

Review finding (blocking readiness): the new layer-3 scheduled workflow has not been live-run, and its current setup cannot execute the declared scenarios.

  • .github/workflows/conformance-differential.yml invokes ./.github/actions/boot-ios-test-simulator without the action's required runtime-version input (the IOS_RUNTIME_VERSION env var does not populate composite-action inputs).
  • The workflow boots a simulator and builds the XCTest runner, but never builds/installs an app for the corpus appId; e.g. settle-after-tap.yaml targets com.example.app and expects both engines to pass. There is no such app installation step.

Please wire a deterministic fixture app/screen for every differential scenario, pass the simulator runtime explicitly, and run the workflow once via workflow_dispatch on this head. Attach the report/trace evidence. Until that succeeds, layer 3 is only a unit-tested runner shape, not the app-observable oracle required by #1274.

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.
@thymikee

Copy link
Copy Markdown
Member Author

All four addressed in 64bc67d35. Thanks — P1 and the fixture-binding P2 were both real holes, and the second one was the oracle quietly decaying back into the thing it replaced.

P1 — layer-3 scenarios could never run. You're right, and the root cause was worse than a missing install step: the scenarios pointed at layer-1 corpus flows, which exist only to be parsed. They name a fictional com.example.app and tap elements like Submit that exist on no device. So bug class 4's detector wasn't just unproven, it was vacuous by construction — it could never have observed a settle loop.

Fixed properly rather than by bolting an install onto fictional flows:

  • Layer 3 now has its own flows in differential/flows/, driving the real fixture app (examples/test-app, com.callstack.agentdevicelab) with real elements (home-open-formCheckout form).
  • The workflow builds and installs it (expo run:ios --configuration Release, so no Metro), and hard-fails via simctl get_app_container if the app isn't installed — a vacuous pass is now impossible.
  • A test enforces the separation, so no scenario can point back at the parse corpus.

On the cron: I removed it. Nothing else in this repo builds or installs the Expo fixture app — perf-nightly doesn't need one — so these steps are new and I could not verify them here. The workflow is now dispatch-only until a supervised first run proves it. A nightly job that fails at 05:00 every day teaches us nothing and trains people to ignore it. The cron goes in as a one-line follow-up once it's green; the comment in the workflow says exactly that.

P2 — Maestro CLI pin. Now reads MAESTRO_VERSION from pinned-upstream.json (so layer 3 cannot drift from what layers 1-2 claim) and asserts maestro --version matches, failing loudly if not.

P2 — fixture content not bound to regeneration. This was the sharpest one: "generated from upstream" was enforced by documentation only. Per-PR CI can't re-derive (Java-free is an ADR constraint), so it's two layers:

  1. Each fixture carries a contentHash seal the verifier recomputes → a hand edit to any captured command or constant fails the build. I tested this by actually tampering: editing retryMaxRetries to 99 on disk turns 4 checks red, and restoring goes green. Tamper-evident, not tamper-proof — a determined editor could recompute it.
  2. New scheduled conformance-regenerate job re-runs the harness against the pinned jars and fails on any byte difference. Forgery can't survive a real re-derivation. That's the half that makes it enforced.

P3 — runtime-version now passed alongside preferred-device-name, matching the other iOS workflows.

Verified: typecheck 0 errors, oxlint, oxfmt, fallow, layering, 161 maestro unit tests, 27 conformance tests (up from 23), fixtures byte-identical across regenerations. I also ran the workflows' two shell one-liners locally (app-id and Maestro-version reads) since those are easy to get wrong silently.

Still unproven, stated plainly: the fixture-app build/install and the device path have never executed. That's exactly what the supervised workflow_dispatch run is for — and note it only becomes dispatchable once this lands, since workflow_dispatch registers from the default branch.

@thymikee

Copy link
Copy Markdown
Member Author

Current-head follow-up (64bc67d35): the earlier simulator/app wiring finding is addressed. Remaining blockers against #1274:

  1. Layer 3 is still workflow_dispatch-only and explicitly unexecuted. Maestro conformance oracle: three generated layers replacing the hand-typed parser fixture #1274 and ADR 0015 require the live differential to run scheduled. Run it successfully on this head, attach the report/trace, then add the schedule; otherwise remove Closes #1274 and track layer 3 separately.
  2. tap-retry-if-no-change.yaml navigates on the first successful tap. It passes without any retry and the trace does not expose attempt count, so it cannot catch a retry regression. Use a fixture control that intentionally needs a retry or record/assert attempts.
  3. percent-swipe.yaml has no boundary-sensitive postcondition or endpoint invariant. As scenarios.ts itself notes, pass/pass outcome parity cannot distinguish truncation from rounding. Add an app-observable boundary result or record/assert resolved pixel endpoints.

Also, current-head iOS Smoke is still running, so this head is not yet CI-complete.

…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.
@thymikee

Copy link
Copy Markdown
Member Author

Re: the layer-3 readiness finding — first two bullets were already fixed in 64bc67d35 (pushed before I saw this); the third is now in progress.

Bullet 1 — runtime-version: fixed. Passed explicitly alongside preferred-device-name, as the other iOS workflows do. You're right that env vars don't populate composite-action inputs.

Bullet 2 — no app for the corpus appId: fixed, and the root cause was worse than a missing install step. The scenarios pointed at layer-1 corpus flows, which exist only to be parsedcom.example.app and elements like Submit exist on no device. So the settle detector wasn't just unproven, it was vacuous by construction: no install step could have rescued it, because the flows themselves were fiction. Layer 3 now has its own flows in differential/flows/ driving the real fixture app (examples/test-app, com.callstack.agentdevicelab) with real elements; the workflow builds and installs it and hard-fails via simctl get_app_container if it's missing, so a vacuous pass is now impossible. A test prevents any scenario pointing back at the parse corpus.

Bullet 3 — live run + evidence: in progress, with one wrinkle worth recording.

workflow_dispatch cannot run on this head pre-merge: it only registers once the workflow is on the default branch, so the API returns
HTTP 404: workflow conformance-differential.yml not found on the default branch.
The request as worded is unsatisfiable until this lands — chicken-and-egg.

To honour the intent rather than the letter, I pushed a temporary, clearly-marked push: trigger scoped to this branch (4e2d0dd-ish, top commit) purely to execute the device path on this head and capture evidence. It will be removed before merge — the workflow comment says so explicitly, and I'll delete it in the same pass that reports results.

Run: https://github.com/callstack/agent-device/actions/runs/29495850346 (queued — macOS runners are backed up). I'll report the outcome and attach the report/trace either way. If the Expo build breaks, that's precisely the information this run exists to produce, and I'd rather find it here than at 05:00 on a cron.

Your framing is right and I'm not going to argue it: until that run succeeds, layer 3 is a unit-tested runner shape, not an app-observable oracle. Layers 1-2 stand on their own evidence (35 identical / 7 both-reject / 17 declared divergences / 0 mismatch, fixtures byte-identical across regenerations, seal proven by actual tampering) — but bug class 4's detector is only real once this goes green.

thymikee added 2 commits July 16, 2026 14:09
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.
…rity 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.
@thymikee

Copy link
Copy Markdown
Member Author

Current-head re-review (84cb81aad): the live differential is now running, but the code still has three readiness blockers.

  1. The workflow remains dispatch-only and contains a temporary branch push trigger marked REMOVE BEFORE MERGE. A successful run must be followed by removing that trigger and adding the required schedule.
  2. tap-retry-if-no-change still navigates on the first successful tap, so deleting retry logic would pass. Trace timing already carries tapRetries; use a fixture that deterministically requires one retry and assert the attempt metric.
  3. percent-truncation still has no endpoint invariant or boundary-sensitive postcondition, and explicitly admits it cannot distinguish rounding from truncation. Record/assert resolved endpoints or make the fixture outcome differ at the one-pixel boundary.

A green live run proves the workflow executes; it does not make these two detectors revert-sensitive.

…tro 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.
@thymikee

Copy link
Copy Markdown
Member Author

Current-head re-review (c37aa5f02): the two detector findings are addressed. tap-retry-if-no-change now targets a non-interactive title and asserts the per-step tapRetries metric; the percent conversion is pinned by a revert-sensitive boundary unit test and the device scenario no longer overclaims what pass/pass proves.

One readiness blocker remains: .github/workflows/conformance-differential.yml is still dispatch-only with a temporary branch push trigger marked REMOVE BEFORE MERGE, while #1274 requires Layer 3 to run scheduled. The live differential is also still in progress. Let it complete successfully, attach/retain the report evidence, remove the temporary trigger, and add the required schedule. Then this is ready for final review.

…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.
@thymikee

Copy link
Copy Markdown
Member Author

Current-head re-review (35842ac): the latest fixes match the failures from run 29500262301. The differential runner now invokes agent-device with --maestro, settle-after-tap uses the fixture app’s established scrollUntilVisible path, and percent-swipe no longer depends on an off-screen navigation control. Independent Terra review found no additional code defect in the revised Layer 3 flows or the Layer 1/2 oracle.

One P1 readiness blocker remains: .github/workflows/conformance-differential.yml is still dispatch-only, retains the temporary branch push trigger marked REMOVE BEFORE MERGE, and has no schedule, while #1274 requires scheduled Layer 3 execution. The fresh live differential for this head is still building the fixture app. Require a successful report/trace from that run, then remove the temporary trigger and add the schedule before merge.

The transient provider-integration failure has already passed on rerun; current required CI is green except iOS Smoke and the live differential, which are still running. I am not applying ready-for-human on this head.

@thymikee

Copy link
Copy Markdown
Member Author

Agreed on the verdict — not ready. Status on each item.

Required CI red (settle-observation.test.ts:214) — resolved, and you were right to demand more than one occurrence

Re-ran the identical commit with no code change:

run 29504444030  run_attempt: 2  head: 35842ac0  conclusion: success
  Integration Tests: success

So it's a flake, not attributable to this PR — now backed by a second data point rather than my say-so. Supporting evidence: it passes locally 4/4 (that test in 1.5s); this PR's entire src/ diff is one new test file, one comment, and one added export, all under compat/maestro; settle-observation.test.ts references compat/maestro zero times; and the three prior CI runs on this branch were green. settle.settled === false on a timing-sensitive settle assertion under CI contention matches a known flake shape in this repo. Required CI on this head is now green.

README contradiction — fixed

Good catch, and it was a real inconsistency: I updated the layer table and the "what layer 3 proves" section but left the bottom section claiming "scheduled". A doc asserting enforcement that the workflow doesn't have is exactly the kind of decorative claim this PR exists to remove. It now points at the workflow's trigger comment as the source of truth and says plainly: treat a dispatch-only trigger as "not yet enforced". Held back from pushing only because a push cancels the in-flight run (cancel-in-progress).

P1 — cron + temporary trigger

No argument: dispatch-only + a temporary branch-scoped push is not #1274's scheduled enforcement, and I won't claim otherwise. The commitment stands — cron in and temporary trigger out, once a run is green. I'm not adding a schedule to a path that has never completed; that would be enforcement in name only.

Evidence

Correct that no run had reached either engine when you looked. Progress since, which narrows what's left:

  • Run 4 got through simulator boot → Expo build (30m) → app installed → simctl bundle-id verify → pinned Maestro 2.5.1 verify, and reached the differential, where real Maestro executed the flows and failed them for two reasons, both mine: the runner omitted --maestro (so test rejected the YAML outright), and two flows tapped home-open-form, which real Maestro reported as not on screen — the app's own helper scrolls it into view first. Both fixed; run 5 is executing now.
  • Worth noting the fail-closed behaviour did its job under real conditions: the settle invariant reported no-data: no completed tapOn steps and failed. Had missing data been treated as a pass, that run would have gone green while proving nothing.

I'll attach the report/trace from run 5 and either add the cron on green, or bring the actual error.

thymikee added 2 commits July 16, 2026 17:11
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.
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).
@thymikee

Copy link
Copy Markdown
Member Author

Current-head re-review (5a4946561): the cron schedule is now present, and the live runs substantively exercised both engines; parking the nondeterministic retry scenario under #1300 is honest. Two blockers remain.

  1. P1 — knownDivergence does not match the declared failure. runScenario() collapses every unexpected engine outcome and every invariant failure into misbehaved, then turns any such failure green whenever the scenario has a declaration. Under the scrollUntilVisible: agent-device fails to find an element real Maestro finds (layer-3 differential) #1299 scrollUntilVisible waiver, Maestro could begin failing too—or a different invariant/regression could appear—and the scheduled job would still pass as known-divergence. This contradicts the code’s “failed exactly as declared” contract and Maestro conformance oracle: three generated layers replacing the hand-typed parser fixture #1274’s expected-divergence requirement. Give declarations a machine-checkable expected failure signature and add tests proving unrelated failures remain red.

  2. The branch-only push trigger remains explicitly marked REMOVE BEFORE MERGE. Exact-head run 29513073502 is still in progress; after it supplies current-head evidence, remove that temporary trigger. The cron and workflow_dispatch triggers should be the merged configuration.

Evidence note: the workflow uploads the computed report but not the replay-timing.ndjson trace used by invariants. Retaining that trace would improve auditability/debugging, though I am not treating it as a separate blocker.

thymikee added 2 commits July 16, 2026 18:11
… 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.
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.
@thymikee

Copy link
Copy Markdown
Member Author

Exact-head re-review (b5b03b794): the prior P1 code blocker is fixed. knownDivergence.expected now matches both engine outcomes and every invariant status exactly; upstream failure, an unexpected agent pass, or different/new invariant results remain red, with focused tests. The scheduled 0 5 * * * cron is retained, and report upload now includes the underlying timing trace.

One merge blocker remains: the explicitly temporary branch-only push trigger is still present and marked REMOVE BEFORE MERGE. Exact-head differential run 29517038101 is also still in progress and has not yet reached both engines or uploaded report/trace evidence; iOS Smoke remains pending. After the run supplies evidence, remove the temporary trigger. No additional code finding in this delta; no ready-for-human yet.

… 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
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.
@thymikee

Copy link
Copy Markdown
Member Author

Both blockers closed. Evidence below.

Layer 3 ran green on this head, against real devices

Run 29519848340 — both engines, real fixture app (com.callstack.agentdevicelab), iPhone 17 Pro / iOS 26.2, pinned Maestro 2.5.1:

known-divergence  settle-after-tap            maestro=pass agent-device=fail
        invariant no-data: no completed tapOn steps in the trace
        declared divergence, tracked: https://github.com/callstack/agent-device/issues/1299
ok                percent-swipe               maestro=pass agent-device=pass
ok                optional-warned-not-failed  maestro=pass agent-device=pass

1 declared divergence(s), not enforced: settle-after-tap

differential-report.json and replay-timing.ndjson are attached to the run (per your evidence note). The optional case is the one worth looking at — real device-verified warned-vs-failed parity, which is one of #1274's named layer-3 goals:

Assert that (Optional) id: this-element-never-exists is visible... WARNED
Assert that "Agent Device Tester" is visible... COMPLETED

Temporary trigger removed (79dc56789)

Merged config is now exactly:

on:
  schedule:
    - cron: "0 5 * * *"
  workflow_dispatch:
    inputs:
      only: ...

No temporary markers remain anywhere (grep -rn "REMOVE BEFORE MERGE\|TEMPORARY" .github/ scripts/maestro-conformance/ → clean). That commit correctly fired no run — GitHub evaluates triggers at the pushed commit, and the push: block is gone in it, so the newest differential run remains 29519848340 on 0aad123a.

Note the run you cited, 29517038101, was cancelled rather than failed — every push cancels the in-flight run via cancel-in-progress, which is the tension inherent in proving a device path pre-merge.

The live run also sharpened #1299 and killed my top hypothesis

Maestro's own log shows it completing the scroll with the same knobs we use:

Scrolling DOWN until id: home-open-form is visible with speed 40,
visibility percentage 100%, timeout 20000 ms, with centering disabled... COMPLETED

I had ranked the visibilityPercentage === 100 boundary as the likeliest cause. Maestro ran at 100% too and still found the element, so that is probably not it — recorded on #1299 so nobody chases it. The stronger lead is container selection: our diagnostics show a [other] "Horizontal scroll bar, 1 page" and a Tab Bar beside the vertical [scroll-area], and driving the wrong scrollable would produce exactly this shape (a clean 20s timeout, not an overshoot). Worth noting the target is a plain RN ScrollView with a title and four buttons — if we cannot scroll that, it likely affects ordinary apps, not just this fixture.

Also in this delta

Local: typecheck 0 errors, lint, format, fallow, layering, 167 unit tests, 40 conformance tests.

@thymikee thymikee merged commit 856d5d4 into main Jul 16, 2026
24 checks passed
@thymikee thymikee deleted the claude/maestro-conformance-oracle-acf8d5 branch July 16, 2026 18:22
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.

Maestro conformance oracle: three generated layers replacing the hand-typed parser fixture

1 participant