diff --git a/.github/workflows/xctest-nightly.yml b/.github/workflows/xctest-nightly.yml index 74f1299f36..aea15a2fd0 100644 --- a/.github/workflows/xctest-nightly.yml +++ b/.github/workflows/xctest-nightly.yml @@ -20,9 +20,30 @@ concurrency: jobs: full-suite: - name: iOS Runner Full XCTest Suite + name: iOS Runner Full XCTest Suite (${{ matrix.leg.name }}) runs-on: macos-26 timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + leg: + # Both legs share the toolchain, the runner build cache, and the artifact steps above. + # The full suite runs on an iPhone, where the sole-screen rule is decided by the idiom + # alone. The iPad leg boots an iPad and runs only the sole-screen tests: the iPad half + # of the rule — the route opens only while the in-process full-screen evidence proves + # the app fills its screen — can execute nowhere else, and the evidence line that probe + # prints is what turns a revert to idiom-only routing red instead of silently green. + # `name-prefix` is what each leg asserts the booted simulator's name starts with: the + # boot action falls back to any simulator when the preferred name is missing, so a + # renamed or absent device model must fail the leg that depends on its idiom. + - name: iPhone full suite + device-name: iPhone 17 Pro + name-prefix: iPhone + lane: nightly + - name: iPad sole-screen evidence + device-name: iPad Pro 11-inch (M5) + name-prefix: iPad + lane: nightly-ipad env: # Must match apple.yml. The runtime version rides in the derived-data cache key, and # AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS is BOTH an input to the cache's build-variant @@ -38,7 +59,7 @@ jobs: AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived RESULT_BUNDLE_PATH: ${{ github.workspace }}/.tmp/xctest-nightly/RunnerTests.xcresult RESULT_SUMMARY_PATH: ${{ github.workspace }}/.tmp/xctest-nightly/test-results-summary.json - XCTEST_LANE: nightly + XCTEST_LANE: ${{ matrix.leg.lane }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -67,7 +88,31 @@ jobs: uses: ./.github/actions/boot-ios-test-simulator with: runtime-version: ${{ env.IOS_RUNTIME_VERSION }} - preferred-device-name: iPhone 17 Pro + preferred-device-name: ${{ matrix.leg.device-name }} + + # The boot action falls back to any simulator when the preferred name is missing, so each + # leg proves it booted the family it claims: the full-suite leg's coverage is iPhone-only at + # the idiom, and the iPad leg's evidence line can only come from an iPad. A renamed or + # removed device model on the image fails here instead of quietly testing the wrong idiom. + - name: Assert the booted simulator's family + run: | + set -euo pipefail + node -e ' + const { execFileSync } = require("node:child_process"); + const [udid, prefix] = process.argv.slice(1); + const payload = JSON.parse( + execFileSync("xcrun", ["simctl", "list", "devices", "-j"], { maxBuffer: 64 * 1024 * 1024 }).toString(), + ); + const device = Object.values(payload.devices ?? {}) + .flat() + .find((entry) => entry.udid === udid); + if (!device?.name?.startsWith(prefix)) { + console.error(`Booted ${device?.name ?? udid}; this leg needs a simulator named "${prefix}…"`); + process.exit(1); + } + console.log(`Booted ${device.name}`); + ' '${{ steps.ios-simulator.outputs.simulator-udid }}' '${{ matrix.leg.name-prefix }}' + shell: bash # Same command as apple.yml's targeted step minus every `-only-testing:` flag, so the # xctestrun's own test plan decides what runs — with one exception. @@ -84,6 +129,7 @@ jobs: # A typo in that identifier silently re-arms the hang, so # `pnpm check:xctest-selection` validates `-skip-testing:` exactly like `-only-testing:`. - name: Run the full iOS runner XCTest suite + if: matrix.leg.lane == 'nightly' run: | set -euo pipefail XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)" @@ -102,6 +148,54 @@ jobs: } ' + # The iPad leg's liveness checks are its own assertions below: it runs a named selection, not + # the whole bundle, so the count assertion the full-suite leg makes does not apply to it, and + # `xcodebuild` exits 0 when a selection matches nothing — a renamed test would shrink the run, + # not fail it. The step asserts the executed count equals the selection, then greps the probe + # line the iPad half of the rule prints. + - name: Run the sole-screen tests on iPad + if: matrix.leg.lane == 'nightly-ipad' + run: | + set -euo pipefail + # This leg skips the full-suite step that creates this directory for the iPhone leg, so + # it must create it in its own leg before `tee` writes into it. + mkdir -p .tmp/xctest-nightly + UDID='${{ steps.ios-simulator.outputs.simulator-udid }}' + XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)" + test -n "$XCTESTRUN_PATH" + xcodebuild test-without-building \ + -xctestrun "$XCTESTRUN_PATH" \ + -destination "platform=iOS Simulator,id=$UDID" \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testLongPressOnAnUnserializableTreeIsSynthesizedOnTheSoleScreen \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testGestureViewportOnAnUnserializableTreeIsTheSoleScreen \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSoleScreenDisplayIDIsNamedOnlyForAnAppThatFillsItsScreen \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testInAppBackVerifiesSoleScreenWhenXCTestCannotReadFramesOrEnumerate \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testInAppBackAfterARejectedQueryTapsThroughTheRouteThatOpens \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testInAppBackAfterARejectedQueryOnAWindowedIpadTapsThroughTheWindow \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testRankedInAppBackUsesSynthesisWithoutXCTestTap \ + -resultBundlePath "$RESULT_BUNDLE_PATH" 2>&1 | tee .tmp/xctest-nightly/runner.log + # `xcodebuild` reports an unmatched selection by running fewer tests, not by failing, so + # the executed count from the result bundle is what proves this leg's whole selection ran. + node --input-type=module -e ' + import { execFileSync } from "node:child_process"; + const summary = JSON.parse(execFileSync( + "xcrun", ["xcresulttool", "get", "test-results", "summary", "--path", process.env.RESULT_BUNDLE_PATH, "--compact"], + { maxBuffer: 64 * 1024 * 1024 }, + ).toString()); + const expected = 7; + if (summary.totalTestCount !== expected) { + throw new Error( + `The iPad sole-screen selection ran ${summary.totalTestCount} tests, expected ${expected}; ` + + "a selection stopped matching its test", + ); + } + ' + # The probe prints this line only when a non-phone idiom asks it. Its absence means the + # iPad never probed (a revert to idiom-only routing), not that a test failed. + grep -q 'AGENT_DEVICE_RUNNER_FULL_SCREEN_EVIDENCE result=fills-screen' \ + .tmp/xctest-nightly/runner.log \ + || { echo 'The iPad sole-screen route never produced full-screen evidence.'; exit 1; } + # Best-effort and never the job's verdict on its own; the step below is what asserts. # `--compact` first because a red night's summary is the large one, and the job summary # has a 1 MiB cap — the plain form is the fallback if an Xcode version rejects the flag. @@ -126,7 +220,7 @@ jobs: # as red too. The failure list is capped so a badly red night cannot blow the 1 MiB # job-summary limit and lose the headline with it. - name: Report the run and assert the source-derived count - if: always() + if: always() && matrix.leg.lane == 'nightly' run: | set -euo pipefail if [ ! -s "$RESULT_SUMMARY_PATH" ]; then @@ -142,7 +236,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: xctest-nightly-results-${{ github.run_id }}-${{ github.run_attempt }} + name: xctest-nightly-${{ matrix.leg.lane }}-${{ github.run_id }}-${{ github.run_attempt }} include-hidden-files: true path: .tmp/xctest-nightly if-no-files-found: warn diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m index a52f7d651e..3014bebcca 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m @@ -672,11 +672,23 @@ - (void)viewDidLoad { bar.items = @[item]; [self.view addSubview:bar]; } else { + // The control must sit under the point the runner's top-leading fallback aims at, whose + // bounds are known: x lands at most 44 from the frame's leading edge, y at + // clamp(safeTopInset + 22, 56, 96). Anchoring to the view's leading (not the safe-area's) + // keeps the button under x=44 on a landscape iPhone, where the side inset would otherwise + // start a safe-area anchor past it; the safe-area top plus 12 with a 64-pt height spans the + // whole clamped y range for every inset an iPhone or iPad reports. UIButton *back = [UIButton buttonWithType:UIButtonTypeSystem]; - back.frame = CGRectMake(12, 62, 88, 44); + back.translatesAutoresizingMaskIntoConstraints = NO; [back setTitle:@"Go back" forState:UIControlStateNormal]; [back addTarget:self action:@selector(navigationBackPressed) forControlEvents:UIControlEventTouchUpInside]; [self.view addSubview:back]; + [NSLayoutConstraint activateConstraints:@[ + [back.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:12], + [back.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor constant:12], + [back.widthAnchor constraintEqualToConstant:88], + [back.heightAnchor constraintEqualToConstant:64], + ]]; } } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift index 952a934d2b..c71fde7267 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift @@ -133,11 +133,12 @@ extension RunnerTests { } /// Samples the same sole-screen route that synthesized gestures use when the app tree refuses - /// its window. This route cannot name a screen on an iPad or a multi-screen iPhone. - func captureSoleAppScreen() -> Result { + /// its window. On an iPad the route opens only while the app provably fills its sole screen, so + /// a windowed app keeps refusing instead of sampling the wrong frame. + func captureSoleAppScreen(app: XCUIApplication) -> Result { var screen: AnyObject? var displayID: UInt = 0 - guard RunnerResolveSoleScreen(&screen, &displayID) == nil, + guard RunnerResolveSoleScreen(app, &screen, &displayID) == nil, let captureScreen = screen as? XCUIScreen else { return .failure(.unresolvedScreen) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m index 7aa7d031c9..8074550cba 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m @@ -333,7 +333,7 @@ static RunnerTapSynthesisStatus RunnerTrySynthesizeTap( NSUInteger displayID = 0; // A nil window is the caller's sole-screen route: the app tree could not name its window. NSString *displayError = resolvedWindow == nil - ? RunnerResolveSoleScreenDisplayID(&displayID) + ? RunnerResolveSoleScreenDisplayID(application, &displayID) : RunnerResolveWindowDisplayID(resolvedWindow, &displayID); if (displayError != nil) return displayError; NSInteger interfaceOrientation = diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift index a0d888c33f..2d811049fe 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift @@ -179,18 +179,44 @@ extension RunnerTests { return candidate.midY >= window.minY && candidate.midY <= maxY } - static func topLeadingNavigationFallbackPoint(in frame: CGRect) -> CGPoint? { + /// The point the top-leading `back` fallback aims at: the leading slot of the bar that sits + /// directly under the top inset of the frame the tap lands in. A fraction of the frame height + /// cannot serve both families — an iPhone 17 Pro measures a 59-pt inset with its bar at y 62-116, + /// while an iPad Pro 11" measures 32 with its bar at y 32-86, where the iPhone's height-derived 96 + /// lands below the bar. `22` is half of UIKit's standard 44-pt top bar. The bounds keep a runtime + /// that reports no inset inside a plausible bar, and a large inset from leaving it. + static func topLeadingNavigationFallbackPoint( + in frame: CGRect, + topSafeAreaInset: CGFloat + ) -> CGPoint? { guard SnapshotGeometry.isPositiveFinite(frame) else { return nil } // Aim at the standard leading navigation slot, bounded for compact and tablet widths. let xOffset = min(max(frame.width * 0.08, 28), 44) - // Sit below the status/dynamic-island region, inside the bar's leading control: on a 402x874 - // iPhone the bar spans y 62-116 and its back control 62-106. - let yOffset = min(max(frame.height * 0.1, 56), 96) + let yOffset = min(max(topSafeAreaInset + 22, 56), 96) return CGPoint(x: frame.minX + xOffset, y: frame.minY + yOffset) } + /// The screen's top inset, read in-process from the runner's own window scene. The status-bar / + /// Dynamic Island band is a screen-level fact, so the runner's full-screen host window reports the + /// one the target app sees — and unlike any element frame it answers on a tree XCTest cannot + /// serialize. The largest top inset among the scene's windows wins: an alert or keyboard window + /// reports none, and the window that carries the band is the one to ask. + private func screenTopSafeAreaInset() -> CGFloat { + #if os(iOS) + let insets = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .map(\.safeAreaInsets.top) + let inset = insets.max() ?? 0 + NSLog("AGENT_DEVICE_RUNNER_BACK_FALLBACK_TOP_INSET read=%.1f windows=%zd", inset, insets.count) + return inset + #else + return 0 + #endif + } + @MainActor private func tapTopLeadingNavigationFallback( app: XCUIApplication, @@ -201,7 +227,10 @@ extension RunnerTests { app: app, policy: synthesizedGesturePolicy(.coordinateTap), windowQueryRejected: windowQueryRejected - ), let point = Self.topLeadingNavigationFallbackPoint(in: context.referenceFrame) else { + ), let point = Self.topLeadingNavigationFallbackPoint( + in: context.referenceFrame, + topSafeAreaInset: screenTopSafeAreaInset() + ) else { return .unavailable } let before = captureNavigationFallbackVisualState(app: app, context: context) @@ -238,7 +267,7 @@ extension RunnerTests { return Self.navigationFallbackSample( resolvingApp: { context.resolvedWindow == nil - ? self.captureSoleAppScreen() + ? self.captureSoleAppScreen(app: app) : self.captureResolvedAppScreen(app: app) }, systemSurface: { self.captureResolvedAppScreen(app: self.springboard) }, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift index d6d6d9e9b4..bcbaf43e14 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift @@ -343,8 +343,8 @@ extension RunnerTests { } /// The viewport the portable gesture planner lays pointer paths in: the frame of the synthesized - /// context its record is routed through, so a tree with no window on an iPhone with one screen - /// plans on that screen, the same route a synthesized tap or scroll takes. + /// context its record is routed through, so a tree with no window whose app provably fills its + /// sole screen plans on that screen, the same route a synthesized tap or scroll takes. @MainActor func gestureViewportFrame(app: XCUIApplication) -> CGRect { #if os(iOS) @@ -518,14 +518,18 @@ extension RunnerTests { #if os(iOS) - /// The reference frame of the sole-screen route, read without the app's accessibility tree: on an - /// iPhone with one screen a full-screen app's window is that screen. A deep app tree makes the - /// windows query fail with `kAXErrorIllegalArgument`, and without this route every synthesized - /// tap and scroll on such a screen failed even though synthesis itself needs no tree. Anything - /// that can show an app in part of a screen (iPad, more than one screen) keeps requiring a window. + /// The reference frame of the sole-screen route, read without the app's accessibility tree: when + /// the app provably fills the device's only screen, its window is that screen. A deep app tree + /// makes the windows query fail with `kAXErrorIllegalArgument`, and without this route every + /// synthesized tap and scroll on such a screen failed even though synthesis itself needs no tree. + /// `RunnerResolveSoleScreen` owns which devices prove that — an iPhone always, an iPad only while + /// the app fills the screen — so this reads the same answer the record's routing takes, and + /// anything else (a windowed iPad, a multi-screen foldable) keeps requiring a window. @MainActor func soleScreenReferenceFrame(app: XCUIApplication) -> CGRect? { - guard UIDevice.current.userInterfaceIdiom == .phone else { + var resolvedScreen: AnyObject? + var resolvedDisplayID: UInt = 0 + guard RunnerResolveSoleScreen(app, &resolvedScreen, &resolvedDisplayID) == nil else { return nil } let frame = soleDisplayFrame( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h index 23649dea38..20bb50aab8 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h @@ -1,4 +1,5 @@ #import +#import NS_ASSUME_NONNULL_BEGIN @@ -79,17 +80,57 @@ FOUNDATION_EXPORT NSString * _Nullable RunnerResolveWindowDisplayID( // Reads the display ID of the device's only screen, for a gesture whose app window XCTest cannot // resolve because the app's accessibility tree is too deep to serialize. On an iPhone with exactly -// one screen that screen hosts the full-screen app, so no window is needed to name it. Anywhere else -// this returns a message and leaves `displayID` untouched: an iPad can show the app in part of its -// screen, and only a window can say which panel lights a foldable's app. -FOUNDATION_EXPORT NSString * _Nullable RunnerResolveSoleScreenDisplayID(NSUInteger *displayID); +// one screen that screen hosts the full-screen app, so no window is needed to name it. On an iPad +// the same route opens only while the app provably fills that screen, decided in-process by the +// evidence seam below without the app's tree. Anywhere else this returns a message and leaves +// `displayID` untouched: a windowed iPad app sits in part of its screen, and only a window can say +// which panel lights a foldable's app. +FOUNDATION_EXPORT NSString * _Nullable RunnerResolveSoleScreenDisplayID( + id application, + NSUInteger *displayID +); // Resolves the screen and display identity under the same sole-screen rule used by synthesis. FOUNDATION_EXPORT NSString * _Nullable RunnerResolveSoleScreen( + id application, id _Nullable *screen, NSUInteger *displayID ); +// What the full-screen proof concluded. The decision is a function of these values, so a test +// supplies them directly instead of restating the device rule. +typedef NS_ENUM(NSUInteger, RunnerFullScreenEvidence) { + // The target app is the accessibility primary app and its frame is the screen's frame. + RunnerFullScreenEvidenceFillsScreen, + // The target app is not the accessibility primary app, so no screen belongs to it to fill. + RunnerFullScreenEvidenceNotPrimaryApp, + // The primary app's frame differs from the system app's frame: an iPad window that shows the app + // in part of the screen (Windowed Apps or Stage Manager). + RunnerFullScreenEvidenceWindowed, + // The runtime, the app, or the system app would not report a usable frame, which proves nothing. + RunnerFullScreenEvidenceUnknown, +}; + +// Decides the evidence from the values the AXRuntime probe reads. AXRuntime reports the primary +// app's window and the system app, which spans the screen, in one native space; this is the same +// comparison the Simulator snapshot bridge refuses a capture on (`app-window-not-full-screen`, +// ADR 0004). Neither read walks the app's tree, so the decision survives a tree XCTest cannot +// serialize. A pid that cannot name a process (<= 0) or an unreadable frame answers Unknown, never +// a guess. +FOUNDATION_EXPORT RunnerFullScreenEvidence RunnerFullScreenEvidenceFromForeground( + pid_t targetProcessID, + pid_t primaryProcessID, + CGRect appFrame, + CGRect screenFrame +); + +// The off-phone half of the sole-screen rule: the route opens only while the evidence proves the +// app fills its screen. The phone idiom never reaches this decision — its caller short-circuits +// before the probe, because an iPhone keeps every app full-screen. +FOUNDATION_EXPORT BOOL RunnerSoleScreenRouteOpensForEvidence( + RunnerFullScreenEvidence evidence +); + FOUNDATION_EXPORT NSString * _Nullable RunnerRequireClass( Class cls, NSString *className, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m index cb7fa39516..3c401e5a54 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m @@ -5,8 +5,57 @@ #import #if TARGET_OS_IOS #import +#import +#import +#import #endif +// AGENT_DEVICE_FULL_SCREEN_RULE_BEGIN +// One rule, two targets. The Simulator snapshot bridge refuses a capture whose app window does not +// fill the screen (`app-window-not-full-screen`, #3844), and the runner refuses to route a +// window-less gesture onto a screen it cannot prove the app fills. The two builds read disjoint +// source roots whose packaged layouts differ, so no include path reaches both; this block is +// carried by both sources and pinned byte-identical by +// `test/repo-guards/full-screen-rule-parity.test.ts`. Each caller keeps its own unknown policy. +@protocol AgentDeviceForegroundElement ++ (nullable id)primaryApp; ++ (nullable id)systemApplication; +- (pid_t)pid; +- (CGRect)frame; +@end + +// While a system surface owns the foreground, no screen is the target app's to fill and no tree is +// the target app's to capture (ADR 0004, foreground ownership). +static BOOL AgentDeviceForegroundOwnershipMatches(pid_t primaryProcessID, pid_t targetProcessID) { + return primaryProcessID == targetProcessID; +} + +// Sub-pixel layout rounding is not a window; anything past this is one that shows the app in part +// of the screen (Windowed Apps or Stage Manager). +static const CGFloat AgentDeviceFullScreenFrameTolerance = 0.5; + +// A null, empty, infinite-sentinel, or non-finite-component frame is not a measurement at all: it +// proves neither the fill nor the window, so it must reach each caller's unknown policy, never a +// comparison that reads as a difference. This is the whole usability test; callers make one call. +static BOOL AgentDeviceForegroundFrameIsMeasurable(CGRect frame) { + if (CGRectIsNull(frame) || CGRectIsEmpty(frame) || CGRectIsInfinite(frame)) return NO; + return isfinite(frame.origin.x) && isfinite(frame.origin.y) && + isfinite(frame.size.width) && isfinite(frame.size.height); +} + +static BOOL AgentDeviceForegroundFramesAreMeasurable(CGRect window, CGRect screen) { + return AgentDeviceForegroundFrameIsMeasurable(window) && + AgentDeviceForegroundFrameIsMeasurable(screen); +} + +static BOOL AgentDeviceForegroundWindowFillsScreen(CGRect window, CGRect screen) { + return fabs(window.origin.x - screen.origin.x) <= AgentDeviceFullScreenFrameTolerance && + fabs(window.origin.y - screen.origin.y) <= AgentDeviceFullScreenFrameTolerance && + fabs(window.size.width - screen.size.width) <= AgentDeviceFullScreenFrameTolerance && + fabs(window.size.height - screen.size.height) <= AgentDeviceFullScreenFrameTolerance; +} +// AGENT_DEVICE_FULL_SCREEN_RULE_END + NSString * _Nullable RunnerResolveXCTestEventBridge( id application, NSString *surface, @@ -95,14 +144,6 @@ return [NSString stringWithFormat:@"%@: %@", name, reason]; } -static BOOL RunnerUsableWindowFrame(CGRect frame) { - if (CGRectIsEmpty(frame) || CGRectIsInfinite(frame) || CGRectIsNull(frame)) { - return NO; - } - return isfinite(frame.origin.x) && isfinite(frame.origin.y) - && isfinite(frame.size.width) && isfinite(frame.size.height); -} - static NSString *RunnerApplicationScreenFailureDescription( RunnerApplicationScreenFailure failure ) { @@ -144,7 +185,7 @@ static RunnerApplicationScreenFailure RunnerResolveUsableWindow( readWindow = candidate; }]; *observedFrame = frame; - if (exception != nil || !resolved || !RunnerUsableWindowFrame(frame)) { + if (exception != nil || !resolved || !AgentDeviceForegroundFrameIsMeasurable(frame)) { NSLog( @"AGENT_DEVICE_RUNNER_APP_SCREEN_UNRESOLVED stage=window frame=(%g,%g %gx%g) raised=%@ resolved=%@", frame.origin.x, @@ -218,17 +259,184 @@ BOOL RunnerResolveApplicationScreen( // routed by the same window its reference frame was measured on. The window still resolves here: // its frame is read before its screen, and a window that answers empty or raises fails closed with // the same reasons the application walk gives. -NSString * _Nullable RunnerResolveSoleScreenDisplayID(NSUInteger *displayID) { +NSString * _Nullable RunnerResolveSoleScreenDisplayID(id application, NSUInteger *displayID) { id screen = nil; - return RunnerResolveSoleScreen(&screen, displayID); + return RunnerResolveSoleScreen(application, &screen, displayID); } -NSString * _Nullable RunnerResolveSoleScreen(id _Nullable *screen, NSUInteger *displayID) { +RunnerFullScreenEvidence RunnerFullScreenEvidenceFromForeground( + pid_t targetProcessID, + pid_t primaryProcessID, + CGRect appFrame, + CGRect screenFrame +) { + // A pid that could not be read (or cannot own a process) proves nothing about ownership, so the + // answer is Unknown, never a guess that the app is not the primary app. + if (targetProcessID <= 0 || primaryProcessID <= 0) { + return RunnerFullScreenEvidenceUnknown; + } + if (!AgentDeviceForegroundOwnershipMatches(primaryProcessID, targetProcessID)) { + return RunnerFullScreenEvidenceNotPrimaryApp; + } + // A frame that is not a usable measurement is a runtime that said nothing, not a window that + // differs, so the answer is Unknown, never the Windowed a comparison of the numbers would read as. + if (!AgentDeviceForegroundFramesAreMeasurable(appFrame, screenFrame)) { + return RunnerFullScreenEvidenceUnknown; + } + return AgentDeviceForegroundWindowFillsScreen(appFrame, screenFrame) + ? RunnerFullScreenEvidenceFillsScreen + : RunnerFullScreenEvidenceWindowed; +} + +BOOL RunnerSoleScreenRouteOpensForEvidence( + RunnerFullScreenEvidence evidence +) { + // An iPad can show an app in part of its only screen, so the route opens only while the proof + // says fills-screen. The phone half of the rule lives in the caller's idiom early return: an + // iPhone keeps every app full-screen, so it never pays for the probe or reaches here. + return evidence == RunnerFullScreenEvidenceFillsScreen; +} + +#if TARGET_OS_IOS + +// The Simulator AX bridge guest loads these two private frameworks and reaches the primary app and +// the system app through the AXElement class the first one defines. The runner reaches the same two +// frames the same way, through the protocol in the shared block above. +static NSString *const RunnerAXRuntimePath = + @"/System/Library/PrivateFrameworks/AXRuntime.framework/AXRuntime"; +static NSString *const RunnerXCTAutomationSupportPath = + @"/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework/XCTAutomationSupport"; + +static Class _Nullable RunnerForegroundElementClass(void) { + // Cache the answer including its absence: a runtime without the class would otherwise pay two + // RTLD_NOW lookups per window-less gesture forever. + static BOOL resolved = NO; + static Class _Nullable cached = Nil; + if (!resolved) { + Class element = objc_lookUpClass("AXElement"); + if (element == Nil) { + // Loading the frameworks is what registers the class; the bridge guest loads the same pair. + dlopen(RunnerAXRuntimePath.UTF8String, RTLD_NOW); + dlopen(RunnerXCTAutomationSupportPath.UTF8String, RTLD_NOW); + element = objc_lookUpClass("AXElement"); + } + cached = (Class)element; + resolved = YES; + } + return cached; +} + +static id _Nullable RunnerForegroundElement( + Class element, + SEL selector +) { + if (![element respondsToSelector:selector]) { + return nil; + } + __block id value = nil; + NSString *exception = [RunnerObjCExceptionCatcher catchException:^{ + value = ((id (*)(id, SEL))objc_msgSend)(element, selector); + }]; + return exception != nil ? nil : value; +} + +static CGRect RunnerForegroundElementFrame(id element) { + if (![element respondsToSelector:@selector(frame)]) { + return CGRectNull; + } + __block CGRect frame = CGRectNull; + NSString *exception = [RunnerObjCExceptionCatcher catchException:^{ + frame = ((CGRect (*)(id, SEL))objc_msgSend)(element, @selector(frame)); + }]; + return exception != nil ? CGRectNull : frame; +} + +// A pid that cannot be read ends as 0, which the decision seam already maps to Unknown: pid 0 is +// never a real process. +static pid_t RunnerForegroundElementPid(id element) { + if (![element respondsToSelector:@selector(pid)]) { + return 0; + } + __block pid_t value = 0; + NSString *exception = [RunnerObjCExceptionCatcher catchException:^{ + value = (pid_t)((int (*)(id, SEL))objc_msgSend)(element, @selector(pid)); + }]; + return exception != nil ? 0 : value; +} + +static RunnerFullScreenEvidence RunnerProbeApplicationFillsScreen(id application) { + if (application == nil) { + return RunnerFullScreenEvidenceUnknown; + } + Class element = RunnerForegroundElementClass(); + if (element == Nil) { + return RunnerFullScreenEvidenceUnknown; + } + id primaryApp = + RunnerForegroundElement(element, @selector(primaryApp)); + id systemApp = + RunnerForegroundElement(element, @selector(systemApplication)); + if (primaryApp == nil || systemApp == nil) { + return RunnerFullScreenEvidenceUnknown; + } + // processID returns pid_t (int32); cast the send through `int` so only the defined low 32 bits + // are read on arm64. + __block NSInteger targetProcessID = 0; + NSString *processIDException = [RunnerObjCExceptionCatcher catchException:^{ + if ([application respondsToSelector:@selector(processID)]) { + targetProcessID = + (NSInteger)((int (*)(id, SEL))objc_msgSend)(application, @selector(processID)); + } + }]; + if (processIDException != nil) { + return RunnerFullScreenEvidenceUnknown; + } + pid_t primaryProcessID = RunnerForegroundElementPid(primaryApp); + CGRect window = RunnerForegroundElementFrame(primaryApp); + CGRect screen = RunnerForegroundElementFrame(systemApp); + RunnerFullScreenEvidence evidence = RunnerFullScreenEvidenceFromForeground( + (pid_t)targetProcessID, + primaryProcessID, + window, + screen + ); + // The flag covers the fills-screen line only: the nightly iPad leg greps for it, so a first + // windowed or unknown answer must not spend the one-shot flag and hide a later fill. A refusal + // stays worth a line each time it routes a gesture away, so it logs every time. + static BOOL loggedFills = NO; + if (evidence == RunnerFullScreenEvidenceFillsScreen && loggedFills) { + return evidence; + } + loggedFills = loggedFills || evidence == RunnerFullScreenEvidenceFillsScreen; + NSLog( + @"AGENT_DEVICE_RUNNER_FULL_SCREEN_EVIDENCE result=%@ targetPid=%d primaryPid=%d window=(%g,%g %gx%g) screen=(%g,%g %gx%g)", + evidence == RunnerFullScreenEvidenceFillsScreen ? @"fills-screen" + : evidence == RunnerFullScreenEvidenceWindowed ? @"windowed" + : evidence == RunnerFullScreenEvidenceNotPrimaryApp ? @"not-primary-app" : @"unknown", + (int)targetProcessID, (int)primaryProcessID, + window.origin.x, window.origin.y, window.size.width, window.size.height, + screen.origin.x, screen.origin.y, screen.size.width, screen.size.height + ); + return evidence; +} + +#endif + +NSString * _Nullable RunnerResolveSoleScreen( + id application, + id _Nullable *screen, + NSUInteger *displayID +) { *screen = nil; #if TARGET_OS_IOS - // Only an iPhone keeps every app full-screen; an iPad can show one in part of its only screen. + // An iPhone keeps every app full-screen, so its idiom answers the rule and the route never pays + // for the probe; off the phone idiom the evidence seam decides. `RunnerProbeApplicationFillsScreen` + // therefore only ever reports what it proved or failed to prove, never "not probed". if (UIDevice.currentDevice.userInterfaceIdiom != UIUserInterfaceIdiomPhone) { - return @"private XCTest event synthesis unavailable: no resolved application window, and only an iPhone routes to its sole screen"; + const RunnerFullScreenEvidence evidence = RunnerProbeApplicationFillsScreen(application); + if (!RunnerSoleScreenRouteOpensForEvidence(evidence)) { + return @"private XCTest event synthesis unavailable: no resolved application window, and the sole screen is not provably the app's full-screen frame"; + } } #else return @"private XCTest event synthesis unavailable: no resolved application window"; @@ -265,7 +473,7 @@ BOOL RunnerResolveApplicationScreen( NSValue *frameValue = [window valueForKey:@"frame"]; [frameValue getValue:&frame size:sizeof(frame)]; }]; - if (frameException != nil || !RunnerUsableWindowFrame(frame)) { + if (frameException != nil || !AgentDeviceForegroundFrameIsMeasurable(frame)) { NSLog( @"AGENT_DEVICE_RUNNER_APP_SCREEN_UNRESOLVED stage=window frame=(%g,%g %gx%g) raised=%@", frame.origin.x, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift index 08e8722ed7..32785d0746 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift @@ -50,10 +50,13 @@ extension RunnerTests { } } - /// Only an iPhone with one screen can skip the windows query after the tree rejected one; anywhere - /// else the windows query is still asked, and its window carries the tap. + /// After the tree rejects a query, `back` reaches the top-leading control through whichever route + /// is open: the sole screen where the app proves it fills it (both families here), the resolved + /// window everywhere else. The refusal half on a real device is + /// `testSoleScreenDisplayIDIsNamedOnlyForAnAppThatFillsItsScreen` and the windowed-iPad test + /// below, which stand where no simulator here can: its foreground app always fills its screen. @MainActor - func testInAppBackStillAsksForWindowsWhereNoSoleScreenAnswers() throws { + func testInAppBackAfterARejectedQueryTapsThroughTheRouteThatOpens() throws { let status = try launchBackFixture(["--agent-device-back-regression"]) defer { endBackFixture() } @@ -67,8 +70,59 @@ extension RunnerTests { XCTAssertEqual(status.label, "Back performed") } + /// The window route the sole-screen rule leaves for an iPad whose app it cannot prove full: with + /// the probe's primary-app read stubbed to answer nothing (the evidence seam's `unknown`), the + /// route closes, and a rejected tree still dispatches `back` through the resolved window. An + /// iPhone keeps the sole-screen route by idiom — it answers before the probe is ever asked, so no + /// stub can close it there and the window route is the iPad half's own. + @MainActor + func testInAppBackAfterARejectedQueryOnAWindowedIpadTapsThroughTheWindow() throws { + let status = try launchBackFixture(["--agent-device-back-regression"]) + defer { endBackFixture() } + // Runs the real probe once, which is also what registers the AXRuntime class this seam stubs. + XCTAssertTrue(soleScreenRouteIsOpen(for: app)) + guard UIDevice.current.userInterfaceIdiom != .phone else { + return + } + let restoreProbe = stubForegroundPrimaryAppAsUnprovable() + defer { restoreProbe() } + XCTAssertFalse( + soleScreenRouteIsOpen(for: app), + "an iPad whose app the probe cannot prove keeps requiring a resolved window" + ) + + let outcome = try withRejectedAppTree(framesReadable: true, rejections: 1) { + tapInAppBackControl(app: app) + } + guard case .performed = outcome else { + return XCTFail("a refused sole-screen route must still dispatch back through the resolved window") + } + XCTAssertEqual(status.label, "Back performed") + } + + /// Stands in for a device showing the app in part of its screen as far as the probe can tell: the + /// probe's `primaryApp` read answers nothing, which the evidence seam reports as `unknown`. The + /// windowed geometry itself needs an iPad set to Windowed Apps, which no lane runs; `unknown` + /// closes the same route for the same reason — the fill is not proven. + private func stubForegroundPrimaryAppAsUnprovable() -> () -> Void { + let element = NSClassFromString("AXElement") + let method = element.flatMap { class_getClassMethod($0, NSSelectorFromString("primaryApp")) } + guard let method else { + preconditionFailure("AXRuntime did not register AXElement before the probe seam was stubbed") + } + let original = method_getImplementation(method) + let replacement = imp_implementationWithBlock( + { (_: AnyObject) -> AnyObject? in nil } as @convention(block) (AnyObject) -> AnyObject? + ) + method_setImplementation(method, replacement) + return { + method_setImplementation(method, original) + imp_removeBlock(replacement) + } + } + private var showsAppsOnlyFullScreen: Bool { - UIDevice.current.userInterfaceIdiom == .phone && XCUIScreen.screens.count == 1 + soleScreenRouteIsOpen(for: app) } /// Launches the runner app; with `--agent-device-back-regression` it shows a top-leading back @@ -180,21 +234,44 @@ extension RunnerTests { } #endif - /// A 402x874 iPhone's navigation bar spans y 62-116 and its back control 62-106. + /// The fallback point sits inside the top bar on both families, from the screen's own top inset: + /// an iPhone 17 Pro measures a 59-pt inset with its bar at y 62-116; an iPad Pro 11" measures 32 + /// with its bar at y 32-86, where a frame-height fraction that serves the iPhone (0.1·874=87, and + /// the old clamp's 96 at any taller height) lands below the iPad's bar. func testTopLeadingNavigationFallbackPointTargetsHeaderControlBand() throws { - let point = try XCTUnwrap( + let iPhone = try XCTUnwrap( Self.topLeadingNavigationFallbackPoint( - in: CGRect(x: 0, y: 0, width: 402, height: 874) + in: CGRect(x: 0, y: 0, width: 402, height: 874), + topSafeAreaInset: 59 ) ) + XCTAssertEqual(iPhone.x, 32.16, accuracy: 0.01) + XCTAssertEqual(iPhone.y, 81, accuracy: 0.01, "inside the iPhone bar band 62-116") - XCTAssertEqual(point.x, 32.16, accuracy: 0.01) - XCTAssertEqual(point.y, 87.4, accuracy: 0.01) + let iPad = try XCTUnwrap( + Self.topLeadingNavigationFallbackPoint( + in: CGRect(x: 0, y: 0, width: 834, height: 1210), + topSafeAreaInset: 32 + ) + ) + XCTAssertEqual(iPad.x, 44, accuracy: 0.01, "the leading-slot cap keeps a tablet tap inside the bar") + XCTAssertEqual(iPad.y, 56, accuracy: 0.01, "inside the iPad bar band 32-86") } func testTopLeadingNavigationFallbackPointRejectsInvalidFrame() { - XCTAssertNil(Self.topLeadingNavigationFallbackPoint(in: .infinite)) - XCTAssertNil(Self.topLeadingNavigationFallbackPoint(in: .zero)) + XCTAssertNil(Self.topLeadingNavigationFallbackPoint(in: .infinite, topSafeAreaInset: 59)) + XCTAssertNil(Self.topLeadingNavigationFallbackPoint(in: .zero, topSafeAreaInset: 59)) + } + + /// A runtime that reports no inset proves nothing about the band, so the point stays inside the + /// compact bar it used to target; a large inset (a surface that pushed the content down) must not + /// send the tap deep into the page. + func testTopLeadingNavigationFallbackPointStaysBoundedForUnknownAndLargeInsets() throws { + let frame = CGRect(x: 0, y: 0, width: 834, height: 1210) + let noInset = try XCTUnwrap(Self.topLeadingNavigationFallbackPoint(in: frame, topSafeAreaInset: 0)) + XCTAssertEqual(noInset.y, 56, accuracy: 0.01, "the compact bar bound") + let hugeInset = try XCTUnwrap(Self.topLeadingNavigationFallbackPoint(in: frame, topSafeAreaInset: 400)) + XCTAssertEqual(hugeInset.y, 96, accuracy: 0.01, "never leaves the header band") } func testNavigationBackControlRankPrefersBackThenCloseThenCancel() { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift index 6d848a006f..f806d300cd 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift @@ -48,13 +48,17 @@ extension RunnerTests { } } + /// Whether the sole-screen route is open for the launched app: an iPhone with one screen always, + /// an iPad only while the app proves it fills that screen. `RunnerResolveSoleScreen` owns the rule; + /// a test reads its answer rather than restating a device list. private var showsAppsOnlyFullScreen: Bool { - UIDevice.current.userInterfaceIdiom == .phone && XCUIScreen.screens.count == 1 + soleScreenRouteIsOpen(for: app) } /// On a tree XCTest cannot serialize, XCTest's coordinate press reads an infinite app frame and - /// raises an exception that ends the runner test. A long press needs no tree: on an iPhone with one - /// screen it is synthesized on that screen, and anything else keeps the XCTest press. + /// raises an exception that ends the runner test. A long press needs no tree: when the sole-screen + /// route is open for the app it is synthesized on that screen, and anything else keeps the XCTest + /// press. @MainActor func testLongPressOnAnUnserializableTreeIsSynthesizedOnTheSoleScreen() throws { app.launch() @@ -101,8 +105,8 @@ extension RunnerTests { } /// A swipe asks for the gesture viewport before it plans its path. On a tree XCTest cannot - /// serialize, an iPhone with one screen answers with that screen, the frame the planned gesture's - /// record is routed to; anything else keeps requiring a window. + /// serialize, the app whose sole screen it provably fills answers with that screen, the frame the + /// planned gesture's record is routed to; anything else keeps requiring a window. @MainActor func testGestureViewportOnAnUnserializableTreeIsTheSoleScreen() throws { app.launch() @@ -153,19 +157,32 @@ extension RunnerTests { } #if os(iOS) - /// The sole-screen route is the boundary every window-less gesture crosses, so it decides the device - /// rule itself: an iPhone with one screen names that screen, and an iPad refuses, because it can show - /// the app in part of its screen. Run on both device families to cover both answers. - func testSoleScreenDisplayIDIsNamedOnlyForAnIPhoneWithOneScreen() { - var displayID: UInt = 0 - let refusal = RunnerResolveSoleScreenDisplayID(&displayID) - if UIDevice.current.userInterfaceIdiom == .phone, XCUIScreen.screens.count == 1 { - XCTAssertNil(refusal) - XCTAssertNotEqual(displayID, 0) - } else { - XCTAssertNotNil(refusal) - XCTAssertEqual(displayID, 0) + /// The launched runner host app fills its simulator's only screen, so its window-less gestures + /// route through that screen on either family. A windowed iPad app is refused the same route — that + /// case needs an iPad set to Windowed Apps, which the nightly lane does not run; what the rule + /// refuses here is the closest executable negative: on an iPad, an app that is not the + /// accessibility primary app has no screen to fill, so naming one would book a tap on another + /// app's frame. An iPhone keeps the rule by idiom, as before this change. + @MainActor + func testSoleScreenDisplayIDIsNamedOnlyForAnAppThatFillsItsScreen() throws { + app.launch() + mainOwned.app = app + defer { + invalidateCachedTarget(reason: "unit_test_cleanup") + app.terminate() + } + XCTAssertTrue( + soleScreenRouteIsOpen(for: app), + "the foreground app fills the simulator's only screen on both families" + ) + guard UIDevice.current.userInterfaceIdiom != .phone else { + return } + + var displayID: UInt = 0 + let refusal = RunnerResolveSoleScreenDisplayID(springboard, &displayID) + XCTAssertNotNil(refusal, "an iPad app that does not own the screen names no sole screen for its taps") + XCTAssertEqual(displayID, 0) } /// #3899: a synthesized turn swaps the display's side lengths, never a placed window's smaller box. diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+XCTestEventBridgeTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+XCTestEventBridgeTests.swift index a9c7f1accf..c0d508374f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+XCTestEventBridgeTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+XCTestEventBridgeTests.swift @@ -203,5 +203,93 @@ extension RunnerTests { XCTAssertNil(screen) XCTAssertEqual(displayID, 0) } + /// The sole-screen rule's decision, at the seam the production probe feeds: the values the + /// AXRuntime probe reads in, never a device list. The iPad halves below fail without the + /// fills-screen proof, on any family and on the macOS host lane. + func testFullScreenEvidenceDecidesFromForegroundValues() { + let screen = CGRect(x: 0, y: 0, width: 834, height: 1210) + + XCTAssertEqual( + RunnerFullScreenEvidenceFromForeground(100, 100, screen, screen), + .fillsScreen + ) + + // A window that shows the app in part of the screen (Windowed Apps or Stage Manager). + let windowed = CGRect(x: 224, y: 128, width: 386, height: 954) + XCTAssertEqual( + RunnerFullScreenEvidenceFromForeground(100, 100, windowed, screen), + .windowed + ) + + // Sub-pixel layout rounding is not a window; it stays inside the 0.5 tolerance. + XCTAssertEqual( + RunnerFullScreenEvidenceFromForeground(100, 100, screen.offsetBy(dx: 0.4, dy: -0.4), screen), + .fillsScreen + ) + XCTAssertEqual( + RunnerFullScreenEvidenceFromForeground(100, 100, screen.offsetBy(dx: 1, dy: 0), screen), + .windowed + ) + + // A different app owns the foreground, so the screen is not this app's to fill. + XCTAssertEqual( + RunnerFullScreenEvidenceFromForeground(100, 77, screen, screen), + .notPrimaryApp + ) + + // An unreadable pid or frame proves nothing, and the answer is Unknown, never a guess. + XCTAssertEqual( + RunnerFullScreenEvidenceFromForeground(0, 100, screen, screen), + .unknown + ) + XCTAssertEqual( + RunnerFullScreenEvidenceFromForeground(100, 0, screen, screen), + .unknown + ) + XCTAssertEqual( + RunnerFullScreenEvidenceFromForeground(100, 100, .null, screen), + .unknown + ) + XCTAssertEqual( + RunnerFullScreenEvidenceFromForeground(100, 100, screen, .zero), + .unknown + ) + // The infinite sentinel is the framework's own "no measurement" marker (CGRectInfinite's + // components are finite extremes), so the predicate refuses it and the answer is Unknown. + XCTAssertEqual( + RunnerFullScreenEvidenceFromForeground(100, 100, .infinite, screen), + .unknown + ) + // A non-finite frame component is a runtime that said nothing, not a window that differs: + // the answer is Unknown, never the Windowed a comparison of the numbers would read as. + XCTAssertEqual( + RunnerFullScreenEvidenceFromForeground( + 100, 100, CGRect(x: CGFloat.nan, y: 0, width: 834, height: 1210), screen + ), + .unknown + ) + XCTAssertEqual( + RunnerFullScreenEvidenceFromForeground( + 100, 100, screen, CGRect(x: 0, y: 0, width: CGFloat.infinity, height: 1210) + ), + .unknown + ) + } + + /// The routing half of the rule off the phone idiom: anything but the phone idiom routes only on + /// the fills-screen proof. The phone half is the caller's idiom early return, so this seam never + /// sees a phone. + func testSoleScreenRouteOpensOnlyOnProvedFullScreenOffThePhoneIdiom() { + XCTAssertTrue(RunnerSoleScreenRouteOpensForEvidence(.fillsScreen)) + XCTAssertFalse( + RunnerSoleScreenRouteOpensForEvidence(.windowed), + "a windowed iPad app must never book a gesture on the screen frame" + ) + XCTAssertFalse(RunnerSoleScreenRouteOpensForEvidence(.notPrimaryApp)) + XCTAssertFalse( + RunnerSoleScreenRouteOpensForEvidence(.unknown), + "a runtime that cannot prove the fill keeps requiring a window" + ) + } } #endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/SoleScreenRouteReading.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/SoleScreenRouteReading.swift new file mode 100644 index 0000000000..2516545646 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/SoleScreenRouteReading.swift @@ -0,0 +1,17 @@ +import XCTest + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS +/// Shared reading of the sole-screen rule for the runner unit bundle. +/// +/// The rule has one owner (`RunnerResolveSoleScreen`), so a test that asks "does this device family +/// show the app full on its only screen" must read its answer rather than restate a device list. +extension RunnerTests { + /// Whether the launched app can route a window-less gesture through the device's only screen: an + /// iPhone always, an iPad only while the app proves it fills that screen. + func soleScreenRouteIsOpen(for app: XCUIApplication) -> Bool { + var screen: AnyObject? + var displayID: UInt = 0 + return RunnerResolveSoleScreen(app, &screen, &displayID) == nil + } +} +#endif diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.m b/apple/snapshot-bridge/SnapshotBridgeRuntime.m index c59949077b..92e0662579 100644 --- a/apple/snapshot-bridge/SnapshotBridgeRuntime.m +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.m @@ -67,13 +67,52 @@ @protocol XCAccessibilityElementFactory + (nullable XCAccessibilityElement *)elementWithProcessIdentifier:(pid_t)pid; @end -@protocol AXForegroundElement -+ (nullable id)primaryApp; -+ (nullable id)systemApplication; +// AGENT_DEVICE_FULL_SCREEN_RULE_BEGIN +// One rule, two targets. The Simulator snapshot bridge refuses a capture whose app window does not +// fill the screen (`app-window-not-full-screen`, #3844), and the runner refuses to route a +// window-less gesture onto a screen it cannot prove the app fills. The two builds read disjoint +// source roots whose packaged layouts differ, so no include path reaches both; this block is +// carried by both sources and pinned byte-identical by +// `test/repo-guards/full-screen-rule-parity.test.ts`. Each caller keeps its own unknown policy. +@protocol AgentDeviceForegroundElement ++ (nullable id)primaryApp; ++ (nullable id)systemApplication; - (pid_t)pid; - (CGRect)frame; @end +// While a system surface owns the foreground, no screen is the target app's to fill and no tree is +// the target app's to capture (ADR 0004, foreground ownership). +static BOOL AgentDeviceForegroundOwnershipMatches(pid_t primaryProcessID, pid_t targetProcessID) { + return primaryProcessID == targetProcessID; +} + +// Sub-pixel layout rounding is not a window; anything past this is one that shows the app in part +// of the screen (Windowed Apps or Stage Manager). +static const CGFloat AgentDeviceFullScreenFrameTolerance = 0.5; + +// A null, empty, infinite-sentinel, or non-finite-component frame is not a measurement at all: it +// proves neither the fill nor the window, so it must reach each caller's unknown policy, never a +// comparison that reads as a difference. This is the whole usability test; callers make one call. +static BOOL AgentDeviceForegroundFrameIsMeasurable(CGRect frame) { + if (CGRectIsNull(frame) || CGRectIsEmpty(frame) || CGRectIsInfinite(frame)) return NO; + return isfinite(frame.origin.x) && isfinite(frame.origin.y) && + isfinite(frame.size.width) && isfinite(frame.size.height); +} + +static BOOL AgentDeviceForegroundFramesAreMeasurable(CGRect window, CGRect screen) { + return AgentDeviceForegroundFrameIsMeasurable(window) && + AgentDeviceForegroundFrameIsMeasurable(screen); +} + +static BOOL AgentDeviceForegroundWindowFillsScreen(CGRect window, CGRect screen) { + return fabs(window.origin.x - screen.origin.x) <= AgentDeviceFullScreenFrameTolerance && + fabs(window.origin.y - screen.origin.y) <= AgentDeviceFullScreenFrameTolerance && + fabs(window.size.width - screen.size.width) <= AgentDeviceFullScreenFrameTolerance && + fabs(window.size.height - screen.size.height) <= AgentDeviceFullScreenFrameTolerance; +} +// AGENT_DEVICE_FULL_SCREEN_RULE_END + static NSNumber *finiteNumber(double value) { return isfinite(value) ? @(value) : nil; @@ -183,10 +222,11 @@ - (BOOL)assertAutomationMode:(BOOL)wanted - (BOOL)isPrimaryForegroundProcess:(pid_t)pid { - Class elementClass = (Class)objc_lookUpClass("AXElement"); + Class elementClass = (Class)objc_lookUpClass("AXElement"); if (![elementClass respondsToSelector:@selector(primaryApp)]) return NO; - id application = [elementClass primaryApp]; - return [application respondsToSelector:@selector(pid)] && [application pid] == pid; + id application = [elementClass primaryApp]; + return [application respondsToSelector:@selector(pid)] && + AgentDeviceForegroundOwnershipMatches([application pid], pid); } // The snapshot API reports the frames of an app that runs in an iPadOS window relative to that @@ -195,23 +235,22 @@ - (BOOL)isPrimaryForegroundProcess:(pid_t)pid // is refused; a runtime that cannot report either frame keeps the capture. - (BOOL)primaryAppWindowDiffersFromScreen { - Class elementClass = (Class)objc_lookUpClass("AXElement"); + Class elementClass = (Class)objc_lookUpClass("AXElement"); if (![elementClass respondsToSelector:@selector(primaryApp)] || ![elementClass respondsToSelector:@selector(systemApplication)]) { return NO; } - id application = [elementClass primaryApp]; - id system = [elementClass systemApplication]; + id application = [elementClass primaryApp]; + id system = [elementClass systemApplication]; if (![application respondsToSelector:@selector(frame)] || ![system respondsToSelector:@selector(frame)]) { return NO; } CGRect window = [application frame]; CGRect screen = [system frame]; - if (CGRectIsEmpty(window) || CGRectIsEmpty(screen)) return NO; - return fabs(CGRectGetMinX(window) - CGRectGetMinX(screen)) > 0.5 || - fabs(CGRectGetMinY(window) - CGRectGetMinY(screen)) > 0.5 || - fabs(CGRectGetWidth(window) - CGRectGetWidth(screen)) > 0.5 || - fabs(CGRectGetHeight(window) - CGRectGetHeight(screen)) > 0.5; + // The documented unknown-frame fallback: a frame that is not a usable measurement is a runtime + // that reported nothing, not a window that differs from the screen, so the capture is kept. + if (!AgentDeviceForegroundFramesAreMeasurable(window, screen)) return NO; + return !AgentDeviceForegroundWindowFillsScreen(window, screen); } - (nullable id)jsonValue:(id)value name:(NSString *)name diff --git a/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index cc65bfac0d..95dca7102d 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -753,6 +753,8 @@ Both frames use the native space of the screen, also in landscape. If the frames differ, the guest refuses the capture (`app-window-not-full-screen`) before acquisition. This refusal provides evidence about the screen arrangement, not the app, so it has capture scope. If the runtime cannot report either frame, the guest keeps the capture. +The runner's synthesized-gesture route compares the same two frames for the same question (`RunnerFullScreenEvidenceFromForeground`). +An iPad app that proves it fills its sole screen may use that screen as its gesture reference frame when XCTest cannot resolve its window; one that does not, keeps requiring a window. The bridge still refuses and converts nothing; the XCTest fallback publishes a windowed app in screen space (#3899 amendment above). diff --git a/docs/adr/0025-foldable-apple-panels.md b/docs/adr/0025-foldable-apple-panels.md index 6543c705ef..b5e4645d7c 100644 --- a/docs/adr/0025-foldable-apple-panels.md +++ b/docs/adr/0025-foldable-apple-panels.md @@ -153,9 +153,9 @@ The `back` fallback compares before/after captures to determine whether this app Two SpringBoard home-screen captures are byte-identical regardless of the app's behavior. System-surface fallback would therefore report false "unchanged" evidence for the failure under test. The check observes the display of the app's resolved window. -Exception: if XCTest cannot resolve a window on an iPhone with exactly one screen, the check uses the same sole screen as synthesized dispatch. -An iPhone app occupies that screen, so this exception needs no app-tree query. -iPad and multi-screen devices still require a resolved window. +Exception: if XCTest cannot resolve a window and the sole screen is provably the app's full-screen frame, the check uses that screen, the same route synthesized dispatch takes. +An iPhone app always occupies its sole screen; an iPad app does so only while the runner's full-screen proof (`RunnerFullScreenEvidenceFromForeground`) proves it. +A multi-screen device and a windowed iPad app still require a resolved window. If neither route can name the display, typed refusal produces an unknown outcome instead of false no-change evidence from another process (#2728). Required and optional helper consumers differ in their frame requirements. diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 909fe39c17..1e4b5a08a0 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -287,7 +287,7 @@ export const SYSTEM_COMMAND_OUTPUT_SCHEMAS = { const appStateCommandDescription = 'Show foreground app/activity (Android and HarmonyOS; iOS and macOS answer per session)'; const backCommandDescription = - 'Navigate back in the app or through system navigation. Use in-app for the app navigation stack and system when the platform back behavior is required. On a single-screen iPhone, an unreadable app tree can use a top-leading tap with before/after visual verification. A capture failure reports an unknown outcome.'; + 'Navigate back in the app or through system navigation. Use in-app for the app navigation stack and system when the platform back behavior is required. On iOS, a single-screen iPhone or an iPad whose app provably fills its screen can use a top-leading tap when the app tree is unreadable, with before/after visual verification. A capture failure reports an unknown outcome.'; const homeCommandDescription = 'Send the selected device to its home screen. This leaves the app session open but moves the foreground away from the app.'; const orientationCommandDescription = diff --git a/test/repo-guards/full-screen-rule-parity.test.ts b/test/repo-guards/full-screen-rule-parity.test.ts new file mode 100644 index 0000000000..081cceb98b --- /dev/null +++ b/test/repo-guards/full-screen-rule-parity.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'vitest'; + +// The full-screen rule is one fact in two native targets: the Simulator snapshot bridge refuses a +// capture whose app window does not fill the screen, and the iOS runner refuses to route a +// window-less gesture onto a screen it cannot prove the app fills. The two binaries build from +// disjoint source roots whose packaged layouts differ, so no include path reaches both builds. +// Each source therefore carries the rule between the sentinels below, and this guard fails the +// moment the two copies stop being byte-identical — the pinned-equal form of one implementation. + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const RUNNER_BRIDGE = path.join( + ROOT, + 'apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m', +); +const SNAPSHOT_RUNTIME = path.join(ROOT, 'apple/snapshot-bridge/SnapshotBridgeRuntime.m'); +const BEGIN = '// AGENT_DEVICE_FULL_SCREEN_RULE_BEGIN'; +const END = '// AGENT_DEVICE_FULL_SCREEN_RULE_END'; + +test('both native targets carry the byte-identical full-screen rule block', () => { + const runner = ruleBlock(RUNNER_BRIDGE); + const snapshot = ruleBlock(SNAPSHOT_RUNTIME); + assert.equal( + runner, + snapshot, + 'The full-screen rule drifted apart; edit one copy and re-run this guard, or move the rule ' + + 'to a header both builds can include.', + ); +}); + +function ruleBlock(file: string): string { + const text = fs.readFileSync(file, 'utf8'); + const begin = text.indexOf(BEGIN); + const end = text.indexOf(END); + assert.ok(begin >= 0 && end > begin, `${path.basename(file)} no longer marks its rule block`); + return text.slice(begin, end + END.length); +} diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 890ddadf54..08303ab47a 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -118,7 +118,7 @@ iOS Simulator launch behavior: Back, orientation, and hardware buttons: - `back` uses app-owned back navigation: visible in-app back UI on Apple targets, and the back keyevent on Android. `--in-app` names this default explicitly. -- On a single-screen iPhone, `back` can use a top-leading tap when the app tree is unreadable. It compares the screen before and after the tap. If the screen cannot be captured, the command reports an unknown outcome; inspect the screen before you retry. +- On iOS, a single-screen iPhone or an iPad whose app provably fills its screen, `back` can use a top-leading tap when the app tree is unreadable. It compares the screen before and after the tap. If the screen cannot be captured, the command reports an unknown outcome; inspect the screen before you retry. - `back --system` sends system back input: the Android back keyevent, the iOS back gesture, or the tvOS Siri Remote Menu button. macOS has no system back, so it reports unavailable. - `orientation portrait|portrait-upside-down|landscape-left|landscape-right` works on iOS and Android mobile targets only. On ADB-backed Android it turns auto-rotate off, and the lock persists after the session ends until something turns it back on. - `orientation auto` hands rotation back to the sensor on ADB-backed Android, local or Limrun (`accelerometer_rotation=1`, `user_rotation` untouched), and reports `orientation: "auto"`. It does not wait for the display to rotate, because there is no target rotation: the sensor decides, and an emulator usually settles on portrait. Every other backend refuses `auto` with `UNSUPPORTED_OPERATION`.