fix(ios): open the sole-screen gesture route on a full-screen iPad app

TH
33m ago
+759-90

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-frame AgentDeviceForegroundFrameIsMeasurable); the runner's outside RunnerUsableWindowFrame is 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 .m files is 32+/36− (round two trims the comment: −9 lines, and the shipped .m bytes now net below pre-predicate size: runner −184 B, bridge +82 B against b813505, which itself carried the duplicated checks). Unit coverage: the .infinite sentinel case added at the evidence seam; the host lane's window-fixture tests (zero/null/infinite refusal) 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 on Review 3918 R3 iPad, log shows AGENT_DEVICE_RUNNER_FULL_SCREEN_EVIDENCE result=fills-screen targetPid=37893 primaryPid=37893 window=(0,0 834x1210) screen=(0,0 834x1210) and the SpringBoard negative result=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 carries fills-screen + SYNTHESIZED_SOLE_SCREEN_ROUTE resolved={{0, 0}, {834, 1210}}. iPhone heavy feed — press id="heavy-post-0-like" and back work; 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 in testSoleScreenDisplayIDIsNamedOnlyForAnAppThatFillsItsScreen on the iPad lane; app-window-not-full-screen in the bridge is exercised by the windowed scenario in the -Werror conformance compile.
  • pnpm check:xctest-selection (502 declared; host 359, PR 5, nightly 494, 0 dark), the parity guard, and the darwin -Werror bridge compile (49 cases) pass. pnpm check:affected --run --head 6a49be9 passed all runnable checks with exactCommit: 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.