Skip to content

Commit 856d5d4

Browse files
authored
test: replace the hand-typed Maestro fixture with a generated conformance 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.
1 parent c245906 commit 856d5d4

118 files changed

Lines changed: 8519 additions & 1236 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.fallowrc.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"examples/test-app/**",
2929
"scripts/perf/**",
3030
"scripts/layering/**",
31+
"scripts/maestro-conformance/**",
3132
"apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan",
3233
"scripts/write-xcuitest-cache-metadata.mjs"
3334
],
@@ -37,6 +38,11 @@
3738
"file": "src/__tests__/test-utils/index.ts",
3839
"exports": ["*"]
3940
},
41+
{
42+
"comment": "Authoritative supported-command list; sole consumer is the conformance oracle (scripts/maestro-conformance, an ignored tooling tree).",
43+
"file": "src/compat/maestro/program-ir-command-parser.ts",
44+
"exports": ["SUPPORTED_MAESTRO_COMMAND_NAMES"]
45+
},
4046
{
4147
"file": "src/__tests__/test-utils/device-fixtures.ts",
4248
"exports": ["LINUX_DEVICE", "ANDROID_TV_DEVICE", "TVOS_SIMULATOR"]
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
name: "Setup Fixture App"
2+
description: "Build (or restore from cache) the examples/test-app fixture app and install it on the booted iOS simulator"
3+
4+
# Any job that needs a controlled app to drive can use this instead of an Apple
5+
# system app. Building it costs ~22 minutes, so the built .app is cached and the
6+
# build only runs when its sources or the toolchain actually change.
7+
#
8+
# The cache is shared: GitHub caches are per-repository and readable across
9+
# workflows, and a run can restore caches from its own branch or the default
10+
# branch. So once a run on main populates it, every workflow gets the hit. The
11+
# key is computed here from a fixed input list, so all callers agree on it —
12+
# do not fold caller-specific inputs into it, or the cache stops being shared.
13+
#
14+
# Relevant to #320 (move replay coverage off system apps onto a stable fixture).
15+
16+
inputs:
17+
runtime-version:
18+
description: "iOS runtime version (participates in the cache key)"
19+
required: true
20+
device-name:
21+
description: "Simulator device name used for the build destination"
22+
required: false
23+
default: "iPhone 17 Pro"
24+
install:
25+
description: "Install the app onto the booted simulator"
26+
required: false
27+
default: "true"
28+
29+
outputs:
30+
app-path:
31+
description: "Path to the built .app bundle"
32+
value: ${{ steps.locate.outputs.app-path }}
33+
app-id:
34+
description: "Bundle identifier read from the built app's Info.plist"
35+
value: ${{ steps.locate.outputs.app-id }}
36+
cache-hit:
37+
description: "Whether the build was restored from cache"
38+
value: ${{ steps.cache.outputs.cache-hit }}
39+
40+
runs:
41+
using: "composite"
42+
steps:
43+
- name: Resolve Xcode cache key
44+
id: xcode
45+
shell: bash
46+
run: |
47+
set -euo pipefail
48+
XCODE_KEY="$(xcodebuild -version | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g; s/[[:space:]]$//' | tr ' ' '-' | tr -cd '[:alnum:]._-')"
49+
echo "key=$XCODE_KEY" >> "$GITHUB_OUTPUT"
50+
51+
- name: Cache fixture app build
52+
id: cache
53+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3
54+
with:
55+
path: ${{ github.workspace }}/.tmp/fixture-app
56+
# Everything that can change the binary: app sources, native config,
57+
# dependency graph, this action's build logic, and the toolchain.
58+
# Deliberately caller-independent so the cache is shared across workflows.
59+
key: fixture-app-ios-${{ inputs.runtime-version }}-${{ steps.xcode.outputs.key }}-${{ hashFiles('examples/test-app/src/**', 'examples/test-app/app/**', 'examples/test-app/modules/**', 'examples/test-app/app.config.js', 'examples/test-app/package.json', 'examples/test-app/pnpm-lock.yaml', 'examples/test-app/pnpm-workspace.yaml', '.github/actions/setup-fixture-app/action.yml') }}
60+
61+
- name: Build fixture app
62+
if: steps.cache.outputs.cache-hit != 'true'
63+
shell: bash
64+
run: |
65+
set -euo pipefail
66+
# Release embeds the JS bundle, so no Metro server is needed in CI.
67+
pnpm test-app:install
68+
pnpm --dir examples/test-app exec expo run:ios \
69+
--configuration Release \
70+
--device "${{ inputs.device-name }}" \
71+
--no-bundler
72+
# Stash the bundle at a stable path: DerivedData paths are hashed per
73+
# project, so the cache needs somewhere deterministic to keep it.
74+
APP_PATH="$(find "$HOME/Library/Developer/Xcode/DerivedData" -type d -name '*.app' -path '*Release-iphonesimulator*' | head -1)"
75+
if [ -z "$APP_PATH" ]; then
76+
echo "::error::Built the fixture app but could not locate its .app bundle to cache."
77+
exit 1
78+
fi
79+
mkdir -p "${{ github.workspace }}/.tmp/fixture-app"
80+
rm -rf "${{ github.workspace }}/.tmp/fixture-app/"*.app
81+
cp -R "$APP_PATH" "${{ github.workspace }}/.tmp/fixture-app/"
82+
83+
- name: Locate fixture app
84+
id: locate
85+
shell: bash
86+
run: |
87+
set -euo pipefail
88+
APP="$(find "${{ github.workspace }}/.tmp/fixture-app" -maxdepth 1 -type d -name '*.app' | head -1)"
89+
if [ -z "$APP" ]; then
90+
echo "::error::No fixture app bundle at .tmp/fixture-app (restored an empty cache?)."
91+
exit 1
92+
fi
93+
# Read the id from the bundle itself rather than duplicating it here, so
94+
# this cannot drift from what was actually built.
95+
APP_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Info.plist")"
96+
echo "app-path=$APP" >> "$GITHUB_OUTPUT"
97+
echo "app-id=$APP_ID" >> "$GITHUB_OUTPUT"
98+
echo "fixture app: $(basename "$APP") ($APP_ID)"
99+
100+
- name: Install fixture app
101+
if: inputs.install == 'true'
102+
shell: bash
103+
run: |
104+
set -euo pipefail
105+
xcrun simctl install booted "${{ steps.locate.outputs.app-path }}"
106+
# Fail loudly rather than let a caller drive a device with no app: a
107+
# missing app produces scenarios that fail for the wrong reason, or pass
108+
# while proving nothing.
109+
if ! xcrun simctl get_app_container booted "${{ steps.locate.outputs.app-id }}" >/dev/null 2>&1; then
110+
echo "::error::Fixture app ${{ steps.locate.outputs.app-id }} is not installed on the booted simulator."
111+
exit 1
112+
fi
113+
echo "installed ${{ steps.locate.outputs.app-id }}"

.github/workflows/ci.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,28 @@ jobs:
129129
- name: Check affected-selector model
130130
run: node --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/run.test.ts
131131

132+
maestro-conformance:
133+
name: Maestro Conformance Oracle
134+
runs-on: ubuntu-latest
135+
timeout-minutes: 5
136+
steps:
137+
- name: Checkout
138+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
139+
140+
# Unlike the layering/affected guards, this job DOES install deps: the
141+
# verifier parses corpus flows with the live engine, and the Maestro parser
142+
# imports the `yaml` package. Keep install-deps enabled.
143+
- name: Setup toolchain
144+
uses: ./.github/actions/setup-node-pnpm
145+
146+
# Layers 1-2 of the conformance oracle: replay the JVM-generated fixtures
147+
# against the live engine. Deterministic and Java-free — the generated
148+
# fixtures are checked in and only regenerated on an upstream-pin bump. The
149+
# device-backed layer 3 runs on the scheduled conformance-differential
150+
# workflow. See scripts/maestro-conformance/README.md.
151+
- name: Verify Maestro conformance fixtures
152+
run: pnpm maestro:conformance
153+
132154
packaged-cli-node-22-12:
133155
name: Packaged CLI Node 22.12
134156
runs-on: ubuntu-latest
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
name: Conformance Differential
2+
3+
# Layer 3 of the Maestro conformance oracle: run a small set of flows through
4+
# BOTH real Maestro and agent-device on a live device and compare app-observable
5+
# outcomes, plus assert engine-side timing invariants over agent-device's own
6+
# replay trace. This is the only layer that can catch settle-loop ordering, which
7+
# has no reflectable upstream constant.
8+
#
9+
# Scheduled: this path is proven end-to-end (run 29504440599 executed both
10+
# engines against the real fixture app). Scenarios that currently diverge are
11+
# DECLARED via `knownDivergence` in differential/scenarios.ts with a tracking
12+
# issue, so the schedule stays green on known gaps and fails only on undeclared
13+
# ones — the same contract layer 1 has. A declaration that stops reproducing also
14+
# fails, so a fix cannot land while leaving the oracle blind.
15+
16+
on:
17+
schedule:
18+
- cron: "0 5 * * *"
19+
workflow_dispatch:
20+
inputs:
21+
only:
22+
description: "Single scenario id to run (default: all)"
23+
required: false
24+
25+
permissions:
26+
contents: read
27+
28+
concurrency:
29+
group: ci-${{ github.workflow }}-${{ github.ref }}
30+
cancel-in-progress: true
31+
32+
env:
33+
AGENT_DEVICE_CLI: "--experimental-strip-types src/bin.ts"
34+
DIFFERENTIAL_ONLY: ${{ github.event.inputs.only || '' }}
35+
# CI should not phone home, and it keeps `maestro --version` to just the
36+
# version instead of prefixing an analytics notice.
37+
MAESTRO_CLI_NO_ANALYTICS: "1"
38+
39+
jobs:
40+
differential-ios:
41+
name: iOS Conformance Differential
42+
runs-on: macos-26
43+
timeout-minutes: 90
44+
env:
45+
IOS_RUNTIME_VERSION: "26.2"
46+
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
47+
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
48+
steps:
49+
- name: Checkout
50+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
51+
52+
- name: Setup toolchain
53+
uses: ./.github/actions/setup-node-pnpm
54+
55+
- name: Setup Apple replay
56+
uses: ./.github/actions/setup-apple-replay
57+
with:
58+
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
59+
cache-key-prefix: ios-runner-prebuilt
60+
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}
61+
build-command: sh ./scripts/build-xcuitest-apple.sh
62+
xcuitest-platform: ios
63+
64+
- name: Boot simulator
65+
uses: ./.github/actions/boot-ios-test-simulator
66+
with:
67+
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
68+
preferred-device-name: iPhone 17 Pro
69+
70+
# Builds or restores the cached fixture app and installs it. Shared with any
71+
# other job that needs a controlled app to drive (see #320) — the cache is
72+
# repo-wide, so whichever workflow builds it first pays the ~22 minutes and
73+
# the rest get a hit.
74+
- name: Setup fixture app
75+
id: fixture-app
76+
uses: ./.github/actions/setup-fixture-app
77+
with:
78+
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
79+
device-name: iPhone 17 Pro
80+
81+
# The action guarantees *an* app is installed; this asserts it is the one
82+
# the scenarios actually target, so a fixture rename cannot leave the
83+
# differential driving the wrong app.
84+
- name: Verify the installed app is the one the scenarios target
85+
run: |
86+
set -euo pipefail
87+
EXPECTED=$(node --experimental-strip-types -e \
88+
"import('./scripts/maestro-conformance/differential/scenarios.ts').then(m => console.log(m.DIFFERENTIAL_APP_ID))")
89+
ACTUAL="${{ steps.fixture-app.outputs.app-id }}"
90+
echo "expected=$EXPECTED actual=$ACTUAL (cache-hit=${{ steps.fixture-app.outputs.cache-hit }})"
91+
if [ "$ACTUAL" != "$EXPECTED" ]; then
92+
echo "::error::Installed fixture app is $ACTUAL but the scenarios target $EXPECTED; they would fail vacuously."
93+
exit 1
94+
fi
95+
96+
# Pin the Maestro CLI to the SAME version the oracle pins for layers 1-2,
97+
# read from pinned-upstream.json so the two can never drift apart. The
98+
# installer honours MAESTRO_VERSION; assert what actually landed.
99+
- name: Install pinned Maestro CLI
100+
run: |
101+
set -euo pipefail
102+
MAESTRO_VERSION=$(node -p "require('./scripts/maestro-conformance/pinned-upstream.json').version")
103+
export MAESTRO_VERSION
104+
echo "Pinning Maestro CLI to $MAESTRO_VERSION (from pinned-upstream.json)"
105+
curl -fsSL "https://get.maestro.mobile.dev" | bash
106+
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
107+
108+
- name: Verify the Maestro CLI matches the oracle pin
109+
run: |
110+
set -euo pipefail
111+
EXPECTED=$(node -p "require('./scripts/maestro-conformance/pinned-upstream.json').version")
112+
# `maestro --version` can print notices (e.g. the analytics banner)
113+
# around the version, so match the semver line rather than the whole
114+
# output. Empty ACTUAL fails the comparison below, as it should.
115+
ACTUAL=$(maestro --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | tail -1 || true)
116+
echo "expected=$EXPECTED actual=$ACTUAL"
117+
if [ "$ACTUAL" != "$EXPECTED" ]; then
118+
echo "::error::Layer-3 Maestro CLI is $ACTUAL but the oracle pins $EXPECTED. A differential against a different Maestro proves nothing about the pinned behavior."
119+
exit 1
120+
fi
121+
122+
# --trace-root lets the runner find agent-device's replay-timing.ndjson and
123+
# assert engine-side invariants (e.g. the settle loop latches instead of
124+
# burning its budget) — outcome parity alone cannot see that.
125+
- name: Run differential
126+
run: |
127+
node --experimental-strip-types scripts/maestro-conformance/differential/run.ts \
128+
--platform ios \
129+
--out-dir "${{ github.workspace }}/.tmp/conformance-differential" \
130+
--trace-root "${{ github.workspace }}/.agent-device" \
131+
${DIFFERENTIAL_ONLY:+--only "$DIFFERENTIAL_ONLY"}
132+
133+
# Keep the trace, not just the verdict: the engine-side invariants are
134+
# computed FROM replay-timing.ndjson, so without it a report saying
135+
# "invariant violated: tapRetries was 0" cannot be audited or debugged
136+
# after the runner is gone.
137+
- name: Upload report and trace
138+
if: always()
139+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
140+
with:
141+
name: conformance-differential-ios
142+
path: |
143+
.tmp/conformance-differential/differential-report.json
144+
.agent-device/**/replay-timing.ndjson
145+
if-no-files-found: ignore
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Conformance Regenerate Verify
2+
3+
# Re-derives the layer-1/layer-2 fixtures from the pinned upstream Maestro
4+
# artifacts and fails if the checked-in files differ by a single byte.
5+
#
6+
# Why this exists as its own job: per-PR CI must stay Java-free (ADR 0015), so it
7+
# can only recompute the fixtures' content seal. That seal is tamper-EVIDENT — it
8+
# stops accidental or casual hand-editing — but someone could edit a capture and
9+
# recompute the hash. This job closes that hole for real: forgery cannot survive
10+
# an actual re-derivation against the pinned jars. It is what makes "generated
11+
# from upstream" an enforced property rather than a claim in a README.
12+
#
13+
# Scheduled + manual only. It needs a JDK, Gradle, and network access to Maven
14+
# Central, none of which belong on the per-PR path.
15+
16+
on:
17+
schedule:
18+
- cron: "0 6 * * *"
19+
workflow_dispatch:
20+
21+
permissions:
22+
contents: read
23+
24+
concurrency:
25+
group: ci-${{ github.workflow }}-${{ github.ref }}
26+
cancel-in-progress: true
27+
28+
jobs:
29+
regenerate-verify:
30+
name: Regenerate & Verify Fixtures
31+
runs-on: ubuntu-latest
32+
timeout-minutes: 20
33+
steps:
34+
- name: Checkout
35+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
36+
37+
# regenerate.mjs itself needs only Node builtins, but the verify step below
38+
# parses corpus flows with the live engine, which imports the `yaml`
39+
# package — so deps must be installed here.
40+
- name: Setup toolchain
41+
uses: ./.github/actions/setup-node-pnpm
42+
43+
- name: Setup JDK
44+
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
45+
with:
46+
distribution: temurin
47+
java-version: "17"
48+
49+
# Verifies the pinned jar SHA-256s against pinned-upstream.json, runs the
50+
# harness over the corpus, and rewrites fixtures/ + corpus/manifest.json.
51+
- name: Regenerate fixtures from pinned upstream
52+
run: node scripts/maestro-conformance/regenerate.mjs
53+
54+
- name: Fail if regeneration changed anything
55+
run: |
56+
if ! git diff --exit-code -- scripts/maestro-conformance/fixtures scripts/maestro-conformance/corpus/manifest.json; then
57+
echo "::error::Checked-in conformance fixtures do not match a fresh regeneration from the pinned upstream artifacts."
58+
echo "The fixtures are generated: run 'pnpm maestro:conformance:regenerate' and commit the result."
59+
exit 1
60+
fi
61+
echo "Fixtures are byte-identical to a fresh regeneration from Maestro ${{ github.sha }}."
62+
63+
# The seal check also runs per-PR, but assert it here too so a regeneration
64+
# that produced an unsealed/mis-sealed fixture cannot pass silently.
65+
- name: Verify fixture seals and conformance
66+
run: node --experimental-strip-types --test scripts/maestro-conformance/verify.test.ts

0 commit comments

Comments
 (0)