fix(ios): open the sole-screen gesture route on a full-screen iPad app
Summary
Taps, long presses, scroll/swipe/gesture-viewport, and the back fallback fail on an iPad simulator when the app's accessibility tree is too deep for XCTest: the sole-screen synthesized-gesture route accepted only an iPhone, though iPad hosts run the same hostile trees. RunnerFullScreenEvidenceFromForeground now decides the proof in-process by comparing the AXRuntime primary app's frame against the system app's frame — the same rule the snapshot bridge refuses a windowed capture on (app-window-not-full-screen, ADR 0004); neither read walks the app's tree. The comparison, tolerance, foreground-element protocol, primary-pid check, and the whole frame-usability test are one AGENT_DEVICE_FULL_SCREEN_RULE block both sources carry, pinned byte-identical by test/repo-guards/full-screen-rule-parity.test.ts (the two builds' packaged layouts are disjoint, so a shared include cannot reach both; see the review reply). RunnerResolveSoleScreen keeps one owner of the rule, early-returns on the phone idiom before probing, and opens on iPad only on fills-screen; windowed/Stage Manager or unknown keeps requiring a window. The Swift side reads the same single owner (RunnerResolveSoleScreen in soleScreenReferenceFrame), reusing #3920's soleDisplayFrame for the frame itself, so screen-count and screen-space rotation stay #3920's and the device rule stays the bridge's. The back fallback point derives from the screen's top safe-area inset via one formula for every route. The nightly workflow gains an iPad matrix leg inside the existing job (no parallel job).
Validation
Head 6a49be9 (merged origin/main at 45125d7, incl. #3920).
- Predicate round (review 2026-10-25T12:21): the pinned block now owns one
AgentDeviceForegroundFramesAreMeasurable(window, screen)(null / empty / infinite sentinel / non-finite via a single-frameAgentDeviceForegroundFrameIsMeasurable); the runner's outsideRunnerUsableWindowFrameis deleted (its two callers call the block), the bridge's outside empty-frame guard is deleted, and each evidence caller makes one call mapping false to its own unknown policy. Parity green; the block's and callers' net line change across both.mfiles is 32+/36− (round two trims the comment: −9 lines, and the shipped.mbytes now net below pre-predicate size: runner −184 B, bridge +82 B againstb813505, which itself carried the duplicated checks). Unit coverage: the.infinitesentinel case added at the evidence seam; the host lane's window-fixture tests (zero/null/infiniterefusal) exercise the single-frame path. - Runner unit tests (unit-test variant, own derived path): sole-screen/decision selection 9/9 on
Review 3918 R3 iPhone(iOS 27.1), the nightly iPad selection 7/7 onReview 3918 R3 iPad, log showsAGENT_DEVICE_RUNNER_FULL_SCREEN_EVIDENCE result=fills-screen targetPid=37893 primaryPid=37893 window=(0,0 834x1210) screen=(0,0 834x1210)and the SpringBoard negativeresult=not-primary-app. macOS host lane: 359 tests, 0 failures. - Post-merge live re-check (Release fixture artifact at
45125d7+ JS repack, own sims/state dirs): iPad heavy feed —press id="heavy-post-0-like"→Liked posts: 1,press 417 640,longpress 200 560 800→Last action: held post 1,scroll down --pixels 500,swipe 417 900 417 400→Flung,back; the session runner.log carriesfills-screen+SYNTHESIZED_SOLE_SCREEN_ROUTE resolved={{0, 0}, {834, 1210}}. iPhone heavy feed —press id="heavy-post-0-like"andbackwork;AGENT_DEVICE_RUNNER_BACK_FALLBACK_TOP_INSET read=62.0 windows=1; zero probe lines on the phone. Windowed iPad negative: the executable refusal (not-primary-app→ no sole screen) is asserted intestSoleScreenDisplayIDIsNamedOnlyForAnAppThatFillsItsScreenon the iPad lane;app-window-not-full-screenin the bridge is exercised by thewindowedscenario in the-Werrorconformance compile. pnpm check:xctest-selection(502 declared; host 359, PR 5, nightly 494, 0 dark), the parity guard, and the darwin-Werrorbridge compile (49 cases) pass.pnpm check:affected --run --head 6a49be9passed all runnable checks withexactCommit: true.- Nightly dispatch at the final head (the merge does not change the workflow; dispatched because the head changed and the iPad leg is the only executable guard on the iPad half): run https://github.com/callstack/agent-device/actions/runs/40204940799 — iPad leg quotes posted on the review reply.
Base/head comparison for the live table is the pre-PR behavior measured at f4944b5 (see the 2026-10-25 review replies): every heavy-feed gesture timed out or refused; at head each completes in ~1s.