fix(ios): keyboard-dismiss content settle race (#1542) — partial, defect 2 needs a decision - #1559
Open
thymikee wants to merge 1 commit into
Open
fix(ios): keyboard-dismiss content settle race (#1542) — partial, defect 2 needs a decision#1559thymikee wants to merge 1 commit into
thymikee wants to merge 1 commit into
Conversation
#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.
Size Report
Startup median (7 runs, lower is better):
Top changed chunks: no changes in the largest emitted chunks. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
#1542 describes two defects blocking the iOS
checkout-form.adreplay corpus leg (and #1478 P5's evidence):scroll down 0.6reports success but the Form screen's ScrollView content doesn't move.dismiss-overlayfails.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 thatkeyboard.waitForNonExistence(the only thingdismissKeyboard()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, bypassingXCUIElement) 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 recordVideocapture correlated frame-by-frame against the daemon's per-request timing (ios_runner_command_sendspans withdurationMs) shows the ScrollView jump to a wildly over-scrolled position during thekeyboardDismisswindow, well before thescrollcommand even executes — confirming the content is still animating when the drag lands. Reproduced deterministically (5/5 fresh-boot runs) viapnpm 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 underAGENT_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 (
#elsebranch ofdismissKeyboard(), tvOS untouched); no cross-platform command contract or shared TS module touched.Live validation (defect 1)
scroll down 0.6moves ScrollView contentgesture-lab.ad(shared runner code, regression check)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.adstill 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.tsmarks 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 normalXCUIElement.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 ownsnapshotDiagnostics: captures are fast (p95 ~230ms,xctestbackend, 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 (
supportsPostGestureStabilizationgates onisMobilePlatform, 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)
runnerScreenshotStabilitySettledpattern this PR introduces, but on the daemon/TS side and for a different trigger.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 1Test plan
checkout-form.adshould pass 2/2 fresh-boot runspnpm test-app:replay:iosexpecting bothcheckout-form.adandgesture-lab.adgreen