Skip to content

fix(ios): keyboard-dismiss content settle race (#1542) — partial, defect 2 needs a decision - #1559

Merged
thymikee merged 2 commits into
mainfrom
fix/ios-form-scroll-inert
Aug 3, 2026
Merged

fix(ios): keyboard-dismiss content settle race (#1542) — partial, defect 2 needs a decision#1559
thymikee merged 2 commits into
mainfrom
fix/ios-form-scroll-inert

Conversation

@thymikee

@thymikee thymikee commented Aug 2, 2026

Copy link
Copy Markdown
Member

Summary

#1542 describes two defects blocking the iOS checkout-form.ad replay corpus leg (and #1478 P5's evidence):

  1. Scroll-inert: scroll down 0.6 reports success but the Form screen's ScrollView content doesn't move.
  2. Recurring slow-AX deferral: a wedge where snapshot backends get deferred and dismiss-overlay fails.

This PR fixes defect 1's root cause and verifies the fix live. It does not make the corpus green — investigating defect 1 surfaced a second, distinct staleness bug in a shared cross-platform module that is now the sole remaining blocker. That one needs a design decision, so per the task brief I'm stopping short of unilaterally changing shared stabilization semantics and reporting it here instead.

Root cause (defect 1, fixed)

Dismissing the keyboard (keyboard dismiss, which taps the field's "Done" key) triggers the app's own ScrollView content-offset correction — releasing the inset it grew to keep the focused field above the keyboard. That correction is a separate, unsynchronized UIKit/RN animation that keyboard.waitForNonExistence (the only thing dismissKeyboard() waited on) knows nothing about: the keyboard's AX element can disappear well before the screen visually settles.

The very next command in the fixture is scroll down 0.6, which — like all iOS scroll/gesture commands — uses the AX-free synthesized drag lane (XCSynthesizedEventRecord/XCPointerEventPath, bypassing XCUIElement) specifically so scrolling keeps working when XCTest can't serialize the accessibility tree (the #1105/#758 AX-degradation lineage). That lane has no XCTest quiescence wait of its own. So the drag can land mid-animation, and its intended net displacement gets absorbed into stopping the residual content-offset correction instead of adding to it — the "scroll does nothing" symptom.

Evidence: xcrun simctl io recordVideo capture correlated frame-by-frame against the daemon's per-request timing (ios_runner_command_send spans with durationMs) shows the ScrollView jump to a wildly over-scrolled position during the keyboardDismiss window, well before the scroll command even executes — confirming the content is still animating when the drag lands. Reproduced deterministically (5/5 fresh-boot runs) via pnpm build && pnpm clean:daemon && node bin/agent-device.mjs replay examples/test-app/replays/checkout-form.ad --platform ios.

Fix

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift: after the keyboard's AX element vanishes, dismissKeyboard() now polls screenshots (AX-free, same philosophy as the synthesized gesture lane) and only returns once 3 consecutive samples are byte-identical (or a 2s cap elapses). Requiring 3 matches — not 2 — is deliberate: a spring-driven correction can pass through a near-zero-velocity inflection that two adjacent samples can't distinguish from true rest.

The stopping decision (runnerScreenshotStabilitySettled) is extracted as a pure function and covered by unit tests under AGENT_DEVICE_RUNNER_UNIT_TESTS (5 cases: needs-enough-samples, matches-in-window, mid-window-mismatch, failed-capture never counts, only-looks-at-trailing-window). The capture/sleep loop around it is the thin, untestable I/O shell.

Scoped to iOS (#else branch of dismissKeyboard(), tvOS untouched); no cross-platform command contract or shared TS module touched.

Live validation (defect 1)

Check Before fix After fix
scroll down 0.6 moves ScrollView content No — content stays at pre-scroll offset (confirmed via video + fresh snapshot) Yes — lands at the correct "Delivery choices" position (confirmed via video frame at the scroll's completion timestamp)
gesture-lab.ad (shared runner code, regression check) 2/2 clean fresh-boot runs, 31 steps each

Video captures and correlated frame extracts, request logs, and JSON failure payloads for every run (fresh-boot repros before the fix, and the fix verification) are preserved outside the worktree at /private/tmp/ad-1542-artifacts/ on the machine this was developed on (not included in the diff — this is evidence, not a repo asset).

Root cause (defect 2, NOT fixed — needs a decision)

With defect 1 fixed, checkout-form.ad still fails at the same step (click id="shipping-pickup" → "resolved to an off-screen element"), but the mechanism is now different and the on-screen content is actually correct at failure time (verified via video: "Pickup" is visibly on-screen at the moment of the reported failure).

src/daemon/post-gesture-stabilization.ts marks scroll/swipe actions for stabilization, then the next snapshot capture polls up to ~1.5s, comparing consecutive AX-derived "interaction surface signatures," and treats two matching consecutive reads as proof the screen settled. That assumption breaks specifically for the AX-free gesture lane: XCTest's AX tree isn't proactively resynced by a synthesized touch (unlike a normal XCUIElement.tap()), so it can serve a stale-but-internally-consistent tree for a window after the scroll — every poll matches the previous one, "stabilization" declares victory almost immediately (attempt 2, ~200ms in), and the click's off-screen guard then evaluates the pre-scroll node positions. Confirmed via the failure's own snapshotDiagnostics: captures are fast (p95 ~230ms, xctest backend, no slow/deferred flag) — this is stale-but-fast, not literally slow AX, which is why bumping timeouts wouldn't help; the loop exits on match, not on budget exhaustion.

This module is shared across iOS/Android (supportsPostGestureStabilization gates on isMobilePlatform, not a specific OS), so a correct fix changes cross-platform, cross-command stabilization semantics — e.g., cross-checking AX-signature stability against a screenshot signal, or treating "post-gesture signature identical to the pre-gesture baseline" as a "keep polling, don't trust this as final" signal rather than as proof of stability. Per the task brief, that's a design decision I'm not making unilaterally here.

Options for defect 2 (for discussion, not implemented)

  1. Cross-check with a screenshot signal for scroll/swipe stabilization specifically — declare "stable" only when both the AX signature and a screenshot fingerprint agree across polls. Reuses the runnerScreenshotStabilitySettled pattern this PR introduces, but on the daemon/TS side and for a different trigger.
  2. Baseline comparison: capture the interaction-surface signature before the gesture runs; if the post-gesture signature matches the pre-gesture baseline, don't trust "stable" — keep polling past the normal stabilization deadline (with its own bounded cap) or fall back to a screenshot check.
  3. Force AX resync after an AX-free gesture via some XCTest-side invalidation, if such a hook exists, so the next snapshot query can't return the pre-gesture cached tree at all.

Any of these touches the shared stabilization module and needs sign-off before implementation.

Gates run

  • pnpm typecheck, pnpm lint, pnpm format:check — clean (no TS touched by this PR; run to confirm no incidental breakage)
  • pnpm build:xcuitest:ios** TEST BUILD SUCCEEDED **
  • gesture-lab.ad — 2/2 clean fresh-boot runs (regression check on the shared Swift file)
  • checkout-form.ad — still fails at step 11, now due to defect 2 (documented above), not defect 1

Test plan

  • Reviewer sign-off on the defect 2 approach (options above)
  • Once approach is picked: implement, then checkout-form.ad should pass 2/2 fresh-boot runs
  • Re-run pnpm test-app:replay:ios expecting both checkout-form.ad and gesture-lab.ad green

#1542)

Dismissing the keyboard can trigger the app's own ScrollView content-offset
correction (e.g. releasing the inset it grew to keep a focused field above
the keyboard). That correction is a separate, unsynchronized animation that
`keyboard.waitForNonExistence` knows nothing about — the keyboard AX element
can disappear well before the app visually settles. The very next command is
frequently a synthesized, AX-free drag (scroll/gesture, kept AX-free so it
still works under #1105-family AX degradation), which has no XCTest
quiescence wait of its own, so it can land mid-animation and net to zero —
the "scroll does nothing" symptom on the Form screen's checkout-form.ad leg.

Add a bounded, AX-free screenshot-stability wait to dismissKeyboard() so the
runner only returns once the screen has actually stopped changing (or a
generous cap elapses). The stopping decision is a pure function
(runnerScreenshotStabilitySettled) covered by unit tests under
AGENT_DEVICE_RUNNER_UNIT_TESTS; the surrounding capture/sleep loop is the
thin, untestable I/O shell around it.

Live-verified on iPhone 17 Pro / iOS 26.2: the scroll now visually lands at
the correct position (confirmed via screen-recording frame correlation)
instead of leaving content at its pre-scroll offset.

Not a full fix for #1542: the checkout-form.ad corpus leg still fails at the
same step, now because the daemon's shared post-gesture snapshot
stabilization (src/daemon/post-gesture-stabilization.ts) can read a
stale-but-internally-consistent AX tree after the AX-free scroll and
mistake "unchanged across polls" for "settled", so the following click's
off-screen guard sees pre-scroll node positions. That is a cross-platform,
cross-command stabilization semantics change and needs a design decision,
not a unilateral fix here — see the PR description.
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.93 MB 1.93 MB 0 B
JS gzip 618.8 kB 618.8 kB 0 B
npm tarball 737.2 kB 738.5 kB +1.3 kB
npm unpacked 2.59 MB 2.59 MB +3.9 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.3 ms 29.6 ms +2.3 ms
CLI --help 66.6 ms 67.5 ms +0.9 ms

Top changed chunks: no changes in the largest emitted chunks.

@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Review finding:\n\n- [P2] Execute the new screenshot-stability regression in CI. RunnerTests+Keyboard.swift adds six runnerScreenshotStabilitySettled tests behind AGENT_DEVICE_RUNNER_UNIT_TESTS, but .github/workflows/ios.yml uses an explicit -only-testing list that selects none of them. The other runner gate is build-for-testing, so the new logic can regress while every check remains green. Add at least one representative method (or the relevant group) to the targeted iOS test-without-building invocation.\n\nThe production route and scoped defect-1 fix otherwise look sound, and the live evidence supports that partial result. The separate stale-AX defect remains explicitly out of scope, so this PR should not close #1542 as a whole.

@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Addressed the P2 finding: the six RunnerTests+Keyboard.swift runnerScreenshotStabilitySettled tests (behind AGENT_DEVICE_RUNNER_UNIT_TESTS) are now selected by the "Run targeted iOS runner XCTest regressions" step in .github/workflows/ios.yml, alongside the existing 10.

Confirmed the env var actually reaches the tests first (otherwise the selection would be vacuous): AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS: '1' is already set in the job's env: block and is read by scripts/build-xcuitest-apple.sh, which appends -D AGENT_DEVICE_RUNNER_UNIT_TESTS to OTHER_SWIFT_FLAGS — so the CI build already compiles these tests in; they just weren't being run.

Local counterfactual (AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1 pnpm build:xcuitest:ios, then xcodebuild test-without-building against an iPhone 17 Pro / iOS 26.2 simulator, matching CI), same binary both times:

  • Before (original -only-testing list, 10 entries): Executed 10 tests, with 0 failures — none of the six keyboard-stability tests ran, even though strings on the compiled binary confirms they're present (testRunnerScreenshotStabilitySettledFalseOnFailedCapture, ...FalseOnMidWindowMismatch, ...NeedsEnoughSamples, ...OnlyLooksAtTheTrailingWindow, ...RejectsDegenerateRequirement, ...TrueWhenWindowMatches).
  • After (updated list, 16 entries): Executed 16 tests, with 0 failures (0 unexpected) in 19.977 seconds — all six now execute and pass, alongside the original 10.

Closing-linkage check: gh pr view 1559 --json closingIssuesReferences,body returns closingIssuesReferences: []. The title already reads "... — partial, defect 2 needs a decision" and the body describes #1542 descriptively ("#1542 describes two defects...") without any closing keyword, so no edit was needed there.

Gates: pnpm typecheck, pnpm lint, pnpm format:check all clean; actionlint .github/workflows/ios.yml clean.

Pushed as 9d86785d4.

Generated by Claude Code

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 3, 2026
@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Re-review at 9d86785: clean and ready for human merge review. The updated iOS workflow selects all six new screenshot-stability tests in the actual test-without-building invocation; the job enables their compile flag, and exact-head CI shows all six executing and passing (16 RunnerTests total, 0 failures). The prior P2 is fully resolved, all checks are green, and no new actionable issue was found. The separate stale-AX defect remains intentionally out of scope.

@thymikee
thymikee merged commit c186363 into main Aug 3, 2026
28 checks passed
@thymikee
thymikee deleted the fix/ios-form-scroll-inert branch August 3, 2026 07:11
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-03 07:12 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant