* fix(ios): grant the text-entry commit wait time against progress
The synthesized commit wait used a flat 3s deadline, which cannot tell a
throttled simulator input pipeline (characters keep landing, slowly) from a
wedged one (nothing lands) — it condemned both at the same instant and reported
TEXT_INPUT_COMMIT_NOT_OBSERVED over a `type`/`fill` that was still working, on
branches touching no iOS code.
SynthesizedCommitBudget grants time against progress instead: while the observed
value's expected-prefix grows — the same length-only evidence logCommitCadence
already emits — the wait continues, up to a 10s ceiling. A pipeline making no
progress expires at exactly the 3s the flat deadline used, so a wedge is
condemned no later than before. It is a reference type, and the observe/expire
coupling carries a structural guard, because as a struct that coupling would
rest on Swift boxing one captured var and could revert to the flat deadline
silently.
Text-entry readiness' hardware-keyboard fallback also stops returning a
possibly-unfocused element after 0.35s of "no software keyboard seen"; it now
returns only on confirmed focus of the target and re-arms otherwise. And the
keyboard-hidden precondition of
testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden skips rather than fails,
so an environment flip cannot read as a product regression.
The issue's remaining ask — pinning the simulator keyboard preference — is
deliberately not done: measured on a dedicated simulator, per-device
ConnectHardwareKeyboard makes no difference to a headless `simctl boot`, which
always shows the software keyboard. See the PR body for the A/B.
Refs #1874 — not a closing keyword on purpose. This is a mitigation; the
unidentified simulator input-throttle mechanism that issue tracks is untouched
here, so it stays open.
* refactor(ios): move the commit-wait budget into the wait itself
Review follow-up. The budget was a detached object tested in isolation, with a
TypeScript parser asserting that two escaping Swift closures happened to share
it — a guard that only existed because the seam was in the wrong place.
The budget is now a local `var` inside `awaitSynthesizedCommitOutcome` and its
replacement counterpart, advanced from the same observation the progress check
already reads, with the clock injected alongside the existing observation and
pacing seams. Recording progress and asking whether time is up are two
statements in one loop, so there is no coupling left to guard.
The detached tests and the TypeScript wiring guard are deleted. In their place,
four sequence tests drive the shipped waits through a hand-driven clock: a
prefix that keeps growing outlives the flat 3s deadline, a frozen prefix is
condemned at exactly 3s, an indefinitely throttled pipeline stops at the 10s
ceiling, and a value churning between two lengths buys no time. Verified red
first — the two progress tests fail against a no-op `record`, and the two
unchanged-behavior tests stay green.
* fix(ios): grant the text-entry commit wait time against progress
The synthesized commit wait started its clock before reading the field's
placeholder, and that read is an AX round-trip which takes seconds on exactly
the loaded host this budget exists for. Slow setup therefore spent the budget:
with a 3.5s placeholder read the first observation already exceeded the 3s
stall budget, so `type` reported TEXT_INPUT_COMMIT_NOT_OBSERVED after a single
poll — sooner than the flat deadline this replaced, in the one condition it was
written for.
The budget is now two durations, and only the poll loop starts it, from its own
first `now()`. Passing a pre-loop timestamp is no longer expressible. The poll
also takes one clock sample instead of two, so the instant an observation is
recorded at is the instant it is judged against.
testCommitWaitBudgetStartsAtTheLoopRatherThanBeforeIt pins it: 60s of setup
before the wait must still leave the full stall budget. Verified red against a
deadline started outside the loop.
* fix(test-app): stop the form fixture placing its own placeholder in every fill
The `smoke:form-input` half of #1874 is not the commit deadline. This PR's own
iOS lane reproduced it (run 32889322172) and the trace settles it: `wait start
expectedLen=12`, then zero `[DEBUG-1874] poll` lines, then `wait
outcome=notObserved elapsedMs=3608`. The wait never polled — it returned from
the `textMatchesPlaceholder` guard, which refuses before polling because an
empty text field renders its placeholder AS its accessibility value, so a match
cannot prove a commit.
`field-name`'s placeholder was "Ada Lovelace" and every checkout-form suite
fills exactly "Ada Lovelace"; `field-email` had the same collision with
"ada@example.com". Twelve fills across eight files, so `fill` into those fields
is unverifiable by contract. It looked intermittent only because the
synthesized-replacement route is gated on `xCTestChannelPenalized` — it fires
when the host is loaded — which is also why re-running a failed job on the same
commit reproduced it identically.
The collision also made the read-back assertions vacuous: `assertJsonContains(
name, 'Ada Lovelace')` is satisfied by an empty field rendering the placeholder.
Fixed in the fixture rather than in the values, because frozen replay-compat
corpora carry the same fills and must not be edited.
fixture-fill-placeholder-collision.test.ts guards the class: it fails on any
repository fill whose value equals the target field's placeholder.
* refactor(ios): drop the fill/placeholder source guard and flatten the commit deadline
Review: the 83-line guard was a source-reconstruction test, not a fixture
invariant. It regex-parsed JSX and two literal fill spellings and duplicated the
Swift trim/equality rule in TypeScript, so it could stay green while its "every
fill" claim was false — expressions, variables, typed clients and unlisted roots
are all outside what a regex can enumerate. The owning evidence already exists:
the Swift tests prove a placeholder-equal AX value is unobservable, and live
smoke:form-input failed on the prior head for exactly this collision. Deleted;
the two placeholder changes stay.
Same pass over the rest of the change, for the same reason. The commit deadline
was a budget value type, a nested Deadline type and a factory method; it is now
one flat struct the poll loop constructs, with the two durations as defaulted
parameters. Production call sites name no budget at all, tests name one only
when they are asking about time, and SynthesizedCommitBudget.standard and the
tests' unboundedCommitBudget both disappear.
* refactor(ios): split the text-entry readiness and commit-wait seams
Review: the change grew three files past their budgets. Splitting them along the
seams they already had, no behavior change.
RunnerTests+TextEntry.swift (607) keeps the vocabulary, field clearing and value
reading at 259; everything that decides "which element is about to receive text,
and has it taken focus" moves to RunnerTests+TextEntryReadiness.swift at 354.
RunnerTests+SynthesizedTextEntry.swift (503) keeps the private-XCTest synthesis
boundary, the replacement route and the route policies at 356. The commit wait
moves next to the deadline that bounds it: the two waits, the observation and
pacing they poll through, and the value-free cadence line that path may log now
sit together in RunnerTests+SynthesizedCommitDeadline.swift at 206. That also
puts every line touching the polled field value in one file, so
apple-runner-log-redaction.test.ts guards a single surface — its path constant
moves with it.
The deadline's clock and sequence tests leave the policy tests (641 -> 494) for a
sibling RunnerTests+SynthesizedCommitDeadlineTests.swift, which gains the
replacement-route case the review asked for: a growing prefix carries the wait
past the 3s stall budget and the 10s ceiling is what ends it. The injected clock
is now defaulted, so only a test actually asking about time names it.
* refactor(ios): split text-entry target acquisition from readiness
Review residual: the readiness extraction was 354 lines and still owned two
questions. Acquisition — the one-shot tap witness, post-tap stabilization, both
focusTextInputForTextEntry entry points and the refresh point — moves to
RunnerTests+TextEntryFocus.swift (206). Readiness keeps the waits, the keyboard
signals they read and the focus corroboration (158).
The dependency is one-way: acquisition asks readiness, never the reverse, so
waitForTextEntryReadiness and keyboardBecameVisible lose file-private scope and
nothing else does.
* test(gesture): assert pan duration in the iOS gesture-lab replay (#1584)
The only replay exercising the `gesture pan` command class that regressed
in #1562 asserted a counter, which stays green even if the requested
duration collapses — nothing in CI could catch the regression coming back.
Record an observed-duration bucket from a single-pointer Gesture.Pan's
begin/end timestamps in GestureLab.tsx (iOS-only, so Android's raw-touch
transform handling in the same shared component is untouched), render it
as plain text, and assert it with a one-line wait in gesture-lab.ad. No
runner protocol changes needed.
* style: fix oxfmt line-wrap in GestureLab.tsx
* ci(ios): run the pan-duration canary automatically on every PR
gesture-lab.ad (and its new duration assertion) only runs under full:fixture-replays,
which is currently dispatch-only in replays-manual.yml — the PR-triggered ios.yml lane
runs the smoke tier, and replays-nightly.yml no longer carries device replays at all
(#1781 A1). So the #1584 guard could not actually catch a regression automatically.
Split the duration check into its own minimal, isolated replay
(gesture-pan-duration.ad) and run it as a smoke-tier step in ios.yml, so it's cheap
and doesn't depend on gesture-lab.ad's multi-touch commands, which stay full-tier only.
* test: require pan recognition in duration canary
* test(android): restore full-tier lifecycle and observability scenarios (#1781 A1)
The nightly Android job has failed on `click id="automation-request-microphone"`
since the full tier landed: `settings permission reset microphone` runs
`pm revoke`, and revoking a *granted* runtime permission kills the app process,
so the round after an accept clicked into an empty launcher surface.
Reproduced on the pinned CI image (android-36 google_apis_playstore, Pixel 7):
pm revoke leaves pid 4259 alive when the permission is denied and kills it when
it is granted, with NexusLauncher resumed afterwards.
Fixing that exposed the rest of a scenario that had never executed end to end:
the post-revoke readback cold-started on the tabs home instead of Automation lab,
the relaunched Automation lab needed its controls revealed, the Form tab does not
exist on the Automation root route, that section needs the system IME back, and
the IME diagnostic sits above the bottom of the form. The observability scenario
then failed the same way (reveal distances tuned for a taller device) plus an
event-timeline walk whose page was smaller than the events each page read appends.
Validated live against a local Pixel_7_CI emulator (API 36, same profile as the
lane): the full tier now runs bootstrap -> inventory -> automation-system ->
form-input -> keyboard-ime -> capture-close -> lifecycle-system ->
observability-artifacts and stops only in full:fixture-replays.
* test(android): repair the drifted fixture replays and pin the catalog canary (#1781 A1)
Review follow-up. The nested batch regression now checks a sibling card instead of the
notice that owns `dismiss-notice`: resolving a child already proves its parent is present,
so the old target could not fail on its own. Confirmed on a Pixel 7 / API 36 emulator that
`gesture-lab-card` and `dismiss-notice` are on screen together at the scenario's existing
0.3 reveal (both present at 0.2-0.4; the card is gone by 0.5).
Getting a full-tier run to complete then required repairing what the lane had never
executed:
- `01-navigation-scroll.ad` clicked `label="Catalog, 0 new notifications"`. #1543 made the
cart badge conditional, so the live label is `Catalog` — what the iOS twin already used.
- The catalog scroll canary lives inside the scrolling content, and Android accessibility
snapshots carry on-screen nodes only, so every state except the initial `top` was
unobservable: `wait "Catalog scroll: down|bottom|up"` could never pass, whatever the
swipe coordinates were. `stickyHeaderIndices` pins that one line, which makes all four
states readable at any offset on both platforms rather than tuning the .ad around a
canary that scrolls away.
- `gesture-lab-android.ad` started its multi-pointer gestures at y=1040, inside the target
when the file was last repaired but 90px from its top edge after #1567 moved the card
(targets now span y=949-1525). The second pointer landed outside the view, which reads as
"the gesture did nothing". Multi-pointer gestures now start at the target centre, and the
header comment records the geometry they depend on.
Evidence: the lane's own command (`AGENT_DEVICE_ANDROID_E2E_TIER=full` over
smoke-android-emulator.test.ts) passes end to end on a Pixel 7 / API 36 AVD with a
CI-equivalent fixture APK (cached native + head JS through the same repack the workflow
runs): 9/9 scenarios, 153s.
* refactor: route every native selector resolution through the policy interface
#1649 declared the per-caller ambiguity matrix; four native call sites still
bypassed it, spreading `selectorResolutionKnobs(row)` into a raw
`resolveSelectorChain` instead of naming the row. That left the "one
interface" claim aspirational: a caller could restate its contract as engine
knobs and nothing would notice.
- `is` non-exists, `get text`/`get attrs`, find's read actions, and the
covered-selector diagnosis probe now call `resolveSelectorChainWithPolicy`
with their existing row. Semantics are byte-identical: the knob-backed
branch of that interface forwards to the same engine call the call sites
built by hand.
- The façade drops `resolveSelectorChain` and `selectorResolutionKnobs`, so
no knob-taking resolver is reachable from outside the package and a call
site cannot re-acquire the knobs even by accident.
`requireUnique`/`disambiguateAmbiguous` are now named in exactly one
function, which `resolve-with-policy.ts` and the replay resolver both
derive through.
- `get` names the two rows it may consume as a type, so pointing it at any
other ambiguity contract is a compile error.
Tests: selector-read-policy.test.ts pins which row each read command
consumes, end to end, on one ambiguous fixture — the only tree the rows
disagree on. Each assertion was proven red by re-pointing its caller at a
neighbouring row. The knob-consistency check moves into the package beside
the now-private helper. Test call sites that used the raw resolver move to
`resolveRecordedTarget`, the same knobs and the path that actually replays a
recorded chain.
Extracting the failure branch drops `resolveSelectorInteractionTarget` below
the complexity threshold; its `fallow-ignore` waiver is removed (verified
load-bearing before the extraction, unnecessary after).
Closes#1630. Structural stages (occlusion, off-screen, promotion, poll
budget) stay per-caller pipeline code, tracked in #1656.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuKzQWn6WQcMYaAZVvJzdD
* test: observe which node find's row selected, not just that one existed
#1715 review, P2: the find row assertion was only half a pin. `find exists`
returns `found: true` for any resolved node, and the `list` call it leaned on
goes through listFindMatches — a path that consumes no policy row at all. So
repointing findFirstLocatorMatch at `readText` left both assertions green
while selection silently moved from the document-order head to the tiebreak
winner.
Assert through `find get_attrs`, which returns the ref of the node the row
actually selected. Both neighbouring rows are now red: `readText` fails
'@e3' !== '@e2' (the move the old test missed), `readUnique` fails by
refusing the ambiguous screen. `exists` stays as a second, weaker assertion
on the same resolution.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuKzQWn6WQcMYaAZVvJzdD
* refactor: route is exists through the matrix, collapse the double match pass
Follow-up tightening on the same seam.
`is exists` reached findSelectorChainMatch directly while the `readAny` row's
own doc claimed to serve "`exists` and find's read-only actions" — true of the
docs, not of the code, which is the unverifiable-claim shape #1656's review
called out. It now names `readAny`, the row it always described. Equivalent by
construction: both take the first alternative with any match under
requireRect: false, and disclose that alternative's count.
That leaves the root façade with no consumer for findSelectorChainMatch, so it
goes the way of resolveSelectorChain — dropped from the string-only façade,
kept on the published ./ast surface. Its façade-twin type SelectorChainMatch
dies with it (fallow caught it).
resolveSelectorChainWithPolicy matched twice on the uniqueness path: once via
resolveSelectorChain, then again to fill matchedNodes. Hoisting the single
list call above the row switch removes that second pass, collapses two
duplicated ambiguous literals into one helper, and drops a `?? [resolution.node]`
fallback that was unreachable — a resolution implies its alternative matched,
so the list is never null there.
While hoisting: the resolved arm's matchedNodes can describe a different
alternative than resolution.selector, because uniqueness skips an ambiguous
alternative to try the next one. Unreachable today (only first-match callers
read it, where both come from one list), and left as-is rather than silently
changed — but the doc claimed "the alternative it came from", so it now says
what is actually true.
Tests: is exists gets a caller-level pin on the shared ambiguous fixture —
passes with matches: 2 where its fail-closed siblings refuse — proven red by
pointing it at readUnique.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuKzQWn6WQcMYaAZVvJzdD
* test: discriminate is exists's row by alternative, guard the façade structurally
#1715 review, second regression-validity gap. The `is exists` pin observed
only `pass: true` and `matches: 2` on a fixture whose first alternative was
merely TIEBREAKABLE — so disambiguation succeeded there and reported the same
count first-match would. `readAny`, `readText`, and the pre-migration raw
lookup all produced that, and only the readUnique swap I had checked went
red. One mutation proven is not the same as the row being pinned.
`exists` exposes no node ref, so the row has to be read off WHICH alternative
answered. New fixture: alternative one matches two nodes that are genuinely
indistinguishable (same depth, same area, both on screen) so the tiebreak
declines; alternative two matches exactly one. First-match answers from
alternative one; every uniqueness row skips the undecidable alternative and
answers from alternative two. Asserting the selector now separates them —
readText and readUnique both fail with `id="save-unique"` where
`label="Save"` is expected.
Restoring the raw lookup stays behaviourally invisible, though:
findSelectorChainMatch is equivalent to the readAny row it migrated to, which
is precisely why that migration preserved semantics. No fixture assertion can
catch that revert, so the guard is structural — the façade's export list must
not carry resolveSelectorChain, findSelectorChainMatch, or
selectorResolutionKnobs. Follows the packages/maestro index.test.ts
absence-assertion precedent. Verified red by re-exporting the lookup.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuKzQWn6WQcMYaAZVvJzdD
* fix: cover selector routes in device replays
* test: simplify selector replay regression
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(test): repair Android replay fixtures against live device reality
Three Android fixture defects from the #1482/#1484 full-tier suite, none of
which ever executed in CI (both nightlies since failed on adb infra before
the suite ran). All three verified live on a fresh API 36 emulator with a
pixel_7-geometry AVD and a Release fixture APK:
- 01-navigation-scroll.ad clicked label=Catalog, but the expo-router
NativeTabs cart badge leaks '0 new notifications' into the tab's content
description even while hidden, and unselected native tabs expose no child
text node - exact match can never hit. Target the composed label the
device actually exposes (deterministic at fixture start: cart is 0 after
--relaunch). The badge does not leak on iOS, so the iOS twin keeps
label="Catalog".
- checkout-form-android.ad opened by iOS display name 'Agent Device
Tester'; Android open resolves packages (the APK label is
'Agentdevicelab'), so APP_NOT_INSTALLED was guaranteed. Use the package
id, matching gesture-lab-android.ad.
- gesture-lab-android.ad aimed every gesture at y=700, above the gesture
card (its targets span y754-1329 on pixel_7 geometry; the home screen
gained content above the card since authoring). Re-aim pans inside the
exact-two-pointer zone, flings on the image clear of that zone, and
pinch/rotate/transform at the card center. Verified: full suite passes
2/2 via the public test command (20 + 32 steps replayed).
Refs #1478
* docs(test): pin the Android gesture fixture's validated emulator geometry
The re-aimed coordinates are validated on CI's profile (pixel_7 1080x2400
@420); any booted emulator can receive them via test-app:replay:android, so
the fixture and README now say which geometry the numbers mean and what a
mismatch failure looks like. The checkout twin is selector-driven and
unconstrained.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore: drop SkillGym and the repo-health aggregator (#1412 descope)
Remove the SkillGym harness (test/skillgym/), its check-affected lane,
package scripts, and devDependency — the help-conformance bench is now
the single non-gating small-model oracle. skills/ markdown classifies
as docs in the affected-check selector instead of failing open.
Remove scripts/repo-health: its only gating assertion duplicated the
Layering Guard job, its case-count metric imported the deleted SkillGym
suite, and its sole planned consumer (#1424 / PR #1477) was closed with
the Track C descope on #1412.
Verified: check-affected node --test suites, oxfmt, oxlint, tsc,
check:layering, fallow audit vs origin/main, and the full unit suite
(unit-core + subprocess-stub) all pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep
* fix(scripts): fold slow-test budgets into the reporter for production-exports
The Fallow production-exports gate flagged all three budget exports:
their in-file consumer (SLOW_TEST_RATCHET) and the repo-health entry
point that kept the module reachable were both removed in the descope,
leaving the config-loaded reporter as the only consumer — invisible to
--production analysis. The data-only module's second consumer is gone,
so per the boundaries-are-earned norm the constants move into the
reporter instead of gaining a suppression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep
* docs: align skills/ format policy and purge last SkillGym mention
Address both P2 review findings on #1480: the testing-matrix row and
the selector's formatGate both still claimed oxfmt covers skills/,
while selectChecks classifies skills/*.md docs-only (oxfmt ignores
**/*.md, so the claim was a no-op even before). The matrix now states
the docs-only policy and formatGate drops the dead underSkills fact.
The merged examples/README.md index (from #1469) loses its skillgym
mention.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): add Renovate config and enforce packageManager pnpm version in CI
Refs #1422
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: bump pnpm to 11.17.0 and format the whole repo with oxfmt
format/format:check drop their hand-maintained path list: oxfmt already skips
node_modules and honors .gitignore, so the only exclusion list is
.oxfmtrc.json ignorePatterns.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(mutation): accept either quote style in the affected-lane path filter
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore(deps): keep fixture-app runtime deps as individual Renovate PRs
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Pin transitive dependencies past their vulnerable ranges via pnpm
overrides, scoped to the affected major so unrelated majors elsewhere
in the tree stay untouched:
- undici 7.24.7 -> 7.28.0 (root/website): @limrun/api pins undici to
an exact version even in its latest release (0.44.0), so bumping the
direct dependency can't fix this; override the transitive resolution
instead.
- shell-quote 1.8.4 -> 1.10.0 (examples/test-app)
- js-yaml 4.1.1 -> 4.3.0 (examples/test-app)
- brace-expansion 5.0.6 -> 5.0.7 (examples/test-app)
- @babel/core 7.29.0 -> 7.29.7 (examples/test-app)
- ws 7.5.10 -> 7.5.13 (examples/test-app)
Resolves all 13 open Dependabot alerts (7 high, 3 moderate, 3 low).
Splits the test app's build caching by context instead of running one remote
cache for both.
Locally, `expo run:*` caches the native build on disk via the
expo-build-disk-cache provider, keyed by the Expo fingerprint. A second run with
no native change reuses the first build; a screen edit never rebuilds, because
Metro serves JS. This is the original ask — "next time we don't build unless
native changes" — and needs no token, no network, and no custom provider.
In CI, test-app-build-cache.yml builds a Release binary per platform when the
fingerprint has no artifact yet, and publishes it as a GitHub Actions artifact
named `fingerprint.<hash>.<platform>`. Release, not dev-client, so the JS bundle
is embedded and a consuming job needs no Metro. setup-fixture-app installs it by
downloading the artifact and refreshing the JS with @expo/repack-app, so keying
on the native-only fingerprint stays correct — a JS-only change reuses the same
native binary in seconds. It falls back to an inline build when no artifact
exists yet, so a caller is never left without an app.
Release removes the sharp edges the dev-client cache needed. Its simulator .app
is universal (x86_64+arm64) rather than the active-arch-only slice a debug build
emits, so no architecture tag. It links against the SDK but loading is gated by
the deployment target, which the fingerprint already covers, so no toolchain
tag. And the CLI only narrows *debug* builds to the device ABI, so a Release APK
spans every ABI without the undocumented --all-arch flag. The artifact name
collapses to fingerprint plus platform.
This deletes build-cache-provider.js entirely — with it goes the custom Expo
provider that had to reach GitHub from inside @expo/cli, and every workaround
that forced: the fetch-nodeshim User-Agent shim, the arch/Xcode identity, the
upload-intent handoff. CI now talks to the artifacts API with plain `gh api`
outside the patched fetch, and locally the disk cache never hits the network.
The fingerprint comes from @expo/fingerprint's own `fingerprint:generate` (no
--platform, matching what @expo/cli hashes). Gitignoring /ios and /android is
what makes it machine-independent: the library asks the VCS whether the platform
markers are ignored and, concluding CNG, skips hashing them — so a developer's
prebuild output and a fresh CI checkout agree.
conformance-differential consumes setup-fixture-app, so it gains
`permissions: actions: read` for the artifact lookup.
The artifact lookup is non-fatal: a query outage leaves the id empty and
falls through to an inline build like a miss does, rather than exiting the
composite under set -e and turning a cache blip into a caller failure.
test/scripts/setup-fixture-app-fallback-smoke.sh drives that step's real shell
against a failing gh and asserts source=build; ci.yml runs it.
* test: give the tap-retry differential a fixture control that forces the retry
tap-retry-if-no-change was parked in #1289 for being a coin flip: tapRetries
measured 0 in run 29504440599 and 1 in 29510020718 with no change to the flow or
commit. This re-adds it with a control that holds still, so the retry fires every
run.
The original diagnosis (a dynamic cart badge in the tapped title's subtree) had
the right shape but the wrong scope. maestroSnapshotSignature hashes EVERY node
on screen, not the tapped subtree, so no "static region" of the home screen could
have worked. The actual coin flip is the gesture lab's remote image
(reactnative.dev/img/logo-share.png): whether it lands before or after the tap
decides whether the engine sees "changed" and skips the retry.
So the fixture gets a dedicated inert surface — no state, effects, timers,
images, or pressables — presented as a full-screen modal so iOS detaches the
presenting screen and the tab bar's live badges leave the hierarchy too. On a
real run it is 9 nodes against Settings' 55.
Reached by a launcher on Settings via the Settings TAB, which is a deliberate
choice twice over. A deep link would have kept the launcher out of every other
screen's snapshot, but simctl openurl raises a SpringBoard "Open in app?"
confirmation on iOS 26 even cold, and Maestro's openLink goes through the same
path. And home's "Open settings" button sits below the fold, so reaching it needs
scrollUntilVisible — the engine bug already waived under #1299.
The flow carries no waitForAnimationToEnd: a navigating tap defers a stability
requirement that the next tap settles before resolving its target, so the
baseline signature is already captured on a settled screen. Adding one fails the
flow outright, which is a real engine divergence filed as #1326.
Verified on device (iPhone 17 Pro Max, iOS 26.2, Maestro 2.5.1): 10/10
consecutive differential runs ok, tapRetries [0,0,1] every run — the two
navigating taps correctly do not retry, the inert tap retries exactly once. A
single green run proves nothing here, which is the trap #1300 fell into.
The parking guard in invariants.test.ts is replaced by three guards: the scenario
stays active, carries its tapRetries invariant, and is never waived by a
knownDivergence — a flaky scenario must be fixed, not declared.
Fixes#1300
* chore: gitignore expo prebuild output in the test app
Building the fixture app locally (what .github/actions/setup-fixture-app does in
CI) runs expo prebuild and generates examples/test-app/ios/. It is generated and
untracked but not ignored, so it shows up in git status and a `git commit -a`
would sweep the whole native project in. Same for android/ when building there.
Scoped to examples/test-app, so the repo-root android/ — which holds real tracked
sources like android/ime-helper — is unaffected. Nothing is tracked under either
path today, and the CI fixture-app cache key hashes src/**, app/**, modules/**
and the config/lockfiles, so it does not reference these and is unaffected.
* feat: polish replay test progress reporter
* test: stabilize replay reporter cursor test in CI
* refactor: dedupe replay reporter live progress checks
* fix: make Expo build cache path configurable
* test: migrate test app to expo dev client
* docs: align test app device targeting
* docs: clarify dev client setup tradeoffs
* docs: remove stale sdk reference
* fix(daemon): timing-safe token comparison and daemon.json permission hardening
Use crypto.timingSafeEqual (via SHA-256 digests, length-independent) for the
three daemon token checks, and chmod daemon.json to 0600 after writes since
writeFileSync only applies mode on creation.
https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2
* fix(deps): clear CVE-2026-9277 by overriding shell-quote to >=1.8.4 in test-app
Override added to examples/test-app/pnpm-workspace.yaml (package.json-level
overrides are silently ignored for this nested app, see the comment there).
Lockfile change is limited to shell-quote 1.8.3 -> 1.8.4; pnpm audit is clean.
https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: resolve test-app dependabot alerts
The postcss/uuid overrides added in #464 stopped applying once test-app
ended up nested under the repo-root pnpm-workspace.yaml: pnpm only honors
overrides from a workspace root, so test-app's package.json `pnpm.overrides`
were silently ignored and the lockfile drifted back to vulnerable versions.
Move the overrides into a dedicated examples/test-app/pnpm-workspace.yaml so
test-app is its own pnpm root and the overrides are honored, and add scoped
overrides for the two remaining alerts:
- postcss 8.4.49 -> 8.5.12 (XSS in CSS stringify)
- uuid 7.0.3 -> 14.0.0 (missing buffer bounds check)
- ws@8 8.20.0 -> 8.21.0 (uninitialized memory disclosure)
- brace-expansion@5 5.0.5 -> 5.0.6 (ReDoS / max bypass)
ws and brace-expansion overrides are scoped to the vulnerable majors so the
non-vulnerable ws@7 / brace-expansion@1 copies in the tree are left untouched.
* chore: drop dead lodash-es override, document test-app workspace
- Remove the no-op `lodash-es` override from the root package.json (leftover
from #368). lodash-es is no longer in the dependency tree, so the override
resolved to nothing; regenerating the root lockfile is a no-op.
- Add a comment to examples/test-app/pnpm-workspace.yaml explaining why the
file exists, so it isn't "tidied away" and the override drift reintroduced.