From b18479aab749969fcb42520f06fcf988163d7a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 22 Oct 2026 19:02:52 +0200 Subject: [PATCH 1/3] fix(ios): open the sole-screen gesture route on a full-screen iPad app A deep app tree makes XCTest's windows query fail with kAXErrorIllegalArgument, and the synthesized-gesture route that recovers from it accepted only an iPhone: the runner refused to name a screen it could not prove the app filled, so every tap, long press, scroll, swipe, and back fallback on a hostile-tree iPad app failed. iPad hosts run the same hostile trees as iPhone hosts. RunnerApplicationFillsScreen now answers that proof in-process: it compares the AXRuntime primary app's frame against the system app's frame, the same comparison the Simulator snapshot bridge refuses a windowed capture on (app-window-not-full-screen, ADR 0004). Neither read walks the app's tree. The sole-screen rule keeps one owner (RunnerResolveSoleScreen) and opens on an iPad only while the evidence says fills-screen; a windowed or Stage Manager app, and any unknown, keeps requiring a resolved window. The back fallback point now derives from the screen's own top safe-area inset instead of a frame-height fraction, which sat below the iPad's navigation bar. Co-Authored-By: Claude --- .github/workflows/xctest-nightly.yml | 89 ++++++++ .../AgentDeviceRunner/AgentDeviceRunnerApp.m | 23 +- .../RunnerAppScreenCapture.swift | 7 +- .../RunnerSynthesizedGesture.m | 2 +- .../RunnerTests+Navigation.swift | 49 ++++- .../RunnerTests+SynthesizedInteraction.swift | 20 +- .../RunnerXCTestEventBridge.h | 49 ++++- .../RunnerXCTestEventBridge.m | 201 +++++++++++++++++- .../RunnerTests+NavigationTests.swift | 54 ++++- ...nerTests+SynthesizedInteractionTests.swift | 48 +++-- .../RunnerTests+XCTestEventBridgeTests.swift | 79 +++++++ .../UnitTests/SoleScreenRouteReading.swift | 17 ++ .../adr/0004-ios-snapshot-backend-strategy.md | 2 + docs/adr/0025-foldable-apple-panels.md | 6 +- src/commands/system/index.ts | 2 +- website/docs/docs/commands.md | 2 +- 16 files changed, 591 insertions(+), 59 deletions(-) create mode 100644 apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/SoleScreenRouteReading.swift diff --git a/.github/workflows/xctest-nightly.yml b/.github/workflows/xctest-nightly.yml index 74f1299f36..4ceb750bcd 100644 --- a/.github/workflows/xctest-nightly.yml +++ b/.github/workflows/xctest-nightly.yml @@ -146,3 +146,92 @@ jobs: include-hidden-files: true path: .tmp/xctest-nightly if-no-files-found: warn + + # The full suite above runs on an iPhone, where the sole-screen rule is decided by the idiom + # alone. The iPad half of the rule — the route opens only while the in-process full-screen + # evidence proves the app fills its screen — can only execute on an iPad. This leg boots one, + # runs the sole-screen tests, and asserts the evidence line the probe prints: no other lane can + # emit it, so a revert to idiom-only routing turns this red instead of silently green. + ipad-sole-screen: + name: iOS Runner Sole-Screen Evidence (iPad) + runs-on: macos-26 + timeout-minutes: 30 + env: + # Byte-identical inputs to the job above, so both restore the same runner cache entry. + IOS_RUNTIME_VERSION: '26.2' + AGENT_DEVICE_XCUITEST_ARCHS: 'arm64' + AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS: '1' + AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + with: + cache-dependency-path: | + pnpm-lock.yaml + examples/test-app/pnpm-lock.yaml + + - name: Restore and build iOS XCTest runner + uses: ./.github/actions/setup-apple-runner-build + with: + derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }} + cache-key-prefix: ios-runner-prebuilt + cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}-arm64 + gate: swift-runner-ios + xcuitest-platform: ios + xcuitest-destination: generic/platform=iOS Simulator + + - name: Boot iPad test simulator + id: ipad-simulator + uses: ./.github/actions/boot-ios-test-simulator + with: + runtime-version: ${{ env.IOS_RUNTIME_VERSION }} + preferred-device-name: iPad Pro 11-inch (M4) + + - name: Run the sole-screen tests on iPad + run: | + set -euo pipefail + UDID='${{ steps.ipad-simulator.outputs.simulator-udid }}' + # The boot action falls back to any simulator when the preferred name is missing. This + # leg proves an iPad-only rule, so a non-iPad fallback must fail here, not at the grep. + xcrun simctl list devices -j | node -e ' + const fs = require("node:fs"); + const udid = process.argv[1]; + const payload = JSON.parse(fs.readFileSync(0, "utf8")); + const device = Object.values(payload.devices ?? {}) + .flat() + .find((entry) => entry.udid === udid); + if (!device?.name?.startsWith("iPad")) { + console.error(`Booted ${device?.name ?? udid}; this leg needs an iPad simulator`); + process.exit(1); + } + ' "$UDID" + XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)" + test -n "$XCTESTRUN_PATH" + mkdir -p .tmp/xctest-nightly-ipad + xcodebuild test-without-building \ + -xctestrun "$XCTESTRUN_PATH" \ + -destination "platform=iOS Simulator,id=${{ steps.ipad-simulator.outputs.simulator-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/testInAppBackStillAsksForWindowsWhereNoSoleScreenAnswers \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testRankedInAppBackUsesSynthesisWithoutXCTestTap \ + 2>&1 | tee .tmp/xctest-nightly-ipad/runner.log + # 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-ipad/runner.log \ + || { echo 'The iPad sole-screen route never produced full-screen evidence.'; exit 1; } + + - name: Upload iPad log + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: xctest-nightly-ipad-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: .tmp/xctest-nightly-ipad + if-no-files-found: warn diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m index d0448365b9..87074beed3 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m @@ -661,17 +661,36 @@ - (void)viewDidLoad { self.navigationBackStatus = label; label.accessibilityIdentifier = @"agent-device-back-status"; if ([NSProcessInfo.processInfo.arguments containsObject:@"--navigation-bar"]) { - UINavigationBar *bar = [[UINavigationBar alloc] initWithFrame:CGRectMake(0, 62, self.view.bounds.size.width, 44)]; + // Same band rule as the plain fixture below: the bar sits under the screen's own top inset, + // which differs by family (59 pt on an iPhone 17 Pro, 32 pt on an iPad Pro), so the bar + // anchors to the safe-area top instead of a hardcoded iPhone y. + UINavigationBar *bar = [[UINavigationBar alloc] init]; + bar.translatesAutoresizingMaskIntoConstraints = NO; UINavigationItem *item = [[UINavigationItem alloc] initWithTitle:@"Thread"]; item.leftBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"Go back" style:UIBarButtonItemStylePlain target:self action:@selector(navigationBackPressed)]; bar.items = @[item]; [self.view addSubview:bar]; + [NSLayoutConstraint activateConstraints:@[ + [bar.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor], + [bar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [bar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [bar.heightAnchor constraintEqualToConstant:44], + ]]; } else { + // The bar band sits under the screen's own top inset, which differs by family (59 pt on an + // iPhone 17 Pro, 32 pt on an iPad Pro). Pinning the control to the safe-area top keeps this + // fixture inside the band the fallback aims at, on either idiom. 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.safeAreaLayoutGuide.leadingAnchor constant:12], + [back.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor], + [back.widthAnchor constraintEqualToConstant:88], + [back.heightAnchor constraintEqualToConstant:44], + ]]; } } 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..34721e3773 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift @@ -179,18 +179,52 @@ 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 screen's inset counts + /// only when the frame starts at the screen's own top (the sole-screen route and a maximized + /// window); a window that starts below the screen top places its bar at its own top instead. 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) + // A screen-top frame takes the screen's inset and the band bounds; a window that starts lower + // places its bar at its own top, so the point centers a standard bar there instead. + if frame.minY > 1 { + return CGPoint(x: frame.minX + xOffset, y: frame.minY + 22) + } + 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 +235,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 +275,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 cde5f867c0..8dbf8efe44 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift @@ -342,8 +342,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) @@ -517,14 +517,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, XCUIScreen.screens.count == 1 else { + var resolvedScreen: AnyObject? + var resolvedDisplayID: UInt = 0 + guard RunnerResolveSoleScreen(app, &resolvedScreen, &resolvedDisplayID) == nil else { return nil } let screen = UIScreen.main diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h index 23649dea38..a63c6b1e8d 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 sole-screen rule with one owner: an iPhone always routes through its only screen; any other +// idiom routes only while the evidence proves the app fills that screen. +FOUNDATION_EXPORT BOOL RunnerSoleScreenRouteOpensForEvidence( + BOOL isPhoneIdiom, + 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..e1a03d47c2 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m @@ -5,8 +5,16 @@ #import #if TARGET_OS_IOS #import +#import +#import +#import #endif +// How far apart two screen-native frames may be and still name the same rectangle. The Simulator +// snapshot bridge refuses a windowed capture on the same tolerance (#3844). The two targets compile +// independently; this constant's owner is the runner's synthesized-gesture route. +static const CGFloat RunnerFullScreenFrameTolerance = 0.5; + NSString * _Nullable RunnerResolveXCTestEventBridge( id application, NSString *surface, @@ -218,17 +226,198 @@ 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); +} + +static BOOL RunnerFramesAgree(CGRect window, CGRect screen) { + return fabs(window.origin.x - screen.origin.x) <= RunnerFullScreenFrameTolerance && + fabs(window.origin.y - screen.origin.y) <= RunnerFullScreenFrameTolerance && + fabs(window.size.width - screen.size.width) <= RunnerFullScreenFrameTolerance && + fabs(window.size.height - screen.size.height) <= RunnerFullScreenFrameTolerance; +} + +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; + } + // The screen to fill is the one the target app owns. A bridge capture is refused the same way when + // a system surface owns the foreground (ADR 0004, foreground ownership). + if (primaryProcessID != targetProcessID) { + return RunnerFullScreenEvidenceNotPrimaryApp; + } + if (CGRectIsNull(appFrame) || CGRectIsNull(screenFrame) || + CGRectIsEmpty(appFrame) || CGRectIsEmpty(screenFrame)) { + return RunnerFullScreenEvidenceUnknown; + } + return RunnerFramesAgree(appFrame, screenFrame) + ? RunnerFullScreenEvidenceFillsScreen + : RunnerFullScreenEvidenceWindowed; +} + +BOOL RunnerSoleScreenRouteOpensForEvidence( + BOOL isPhoneIdiom, + RunnerFullScreenEvidence evidence +) { + // An iPhone keeps every app full-screen, so its sole screen is that app's frame whatever the + // probe reports. An iPad can show an app in part of its only screen, so the route opens only + // while the proof says fills-screen. + return isPhoneIdiom || 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. Neither read walks the app's accessibility tree, so both answer on a tree +// XCTest cannot serialize. +static NSString *const RunnerAXRuntimePath = + @"/System/Library/PrivateFrameworks/AXRuntime.framework/AXRuntime"; +static NSString *const RunnerXCTAutomationSupportPath = + @"/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework/XCTAutomationSupport"; + +@protocol RunnerForegroundElement ++ (nullable id)primaryApp; ++ (nullable id)systemApplication; +- (pid_t)pid; +- (CGRect)frame; +@end + +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; } -NSString * _Nullable RunnerResolveSoleScreen(id _Nullable *screen, NSUInteger *displayID) { +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 + ); + static BOOL loggedFills = NO; + if (evidence != RunnerFullScreenEvidenceFillsScreen || !loggedFills) { + loggedFills = YES; + 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. - 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 BOOL isPhoneIdiom = + UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone; + // The probe walks the AX bridge; an iPhone's idiom already decides the rule, so it never pays + // for the read. Unknown on the phone side records "not probed", and the rule still opens. + const RunnerFullScreenEvidence evidence = isPhoneIdiom + ? RunnerFullScreenEvidenceUnknown + : RunnerProbeApplicationFillsScreen(application); + if (!RunnerSoleScreenRouteOpensForEvidence(isPhoneIdiom, 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"; diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift index 08e8722ed7..1bcd8f3dad 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift @@ -50,8 +50,11 @@ 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. + /// Only an app that provably fills its sole 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. + /// The runner host app fills its simulator's screen on both families, so this negative needs a + /// route the device cannot open: SpringBoard's app is not the accessibility primary app on an + /// iPad, and on an iPhone the idiom alone opens the route, where the expectation is the iPhone's. @MainActor func testInAppBackStillAsksForWindowsWhereNoSoleScreenAnswers() throws { let status = try launchBackFixture(["--agent-device-back-regression"]) @@ -68,7 +71,7 @@ extension RunnerTests { } 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 +183,52 @@ 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. A window that starts below the screen's top (a resolvable + /// Stage Manager window) places its bar at its own top, so the screen's inset must not shift it + /// further down. + 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") + + let loweredWindow = CGRect(x: 120, y: 130, width: 600, height: 950) + let lowered = try XCTUnwrap( + Self.topLeadingNavigationFallbackPoint(in: loweredWindow, topSafeAreaInset: 32) + ) + XCTAssertEqual(lowered.y, 152, accuracy: 0.01, "the bar's middle sits at the window's own top plus half a standard bar") } func testNavigationBackControlRankPrefersBackThenCloseThenCancel() { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift index c056141e8c..f2b0245ee1 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() @@ -156,16 +160,32 @@ extension RunnerTests { /// 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) } #endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+XCTestEventBridgeTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+XCTestEventBridgeTests.swift index a9c7f1accf..68c0c3c0d5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+XCTestEventBridgeTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+XCTestEventBridgeTests.swift @@ -203,5 +203,84 @@ 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 + ) + XCTAssertNotEqual( + RunnerFullScreenEvidenceFromForeground(100, 100, screen.offsetBy(dx: 1, dy: 0), screen), + .fillsScreen + ) + + // 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 routing half of the rule: an iPhone always routes through its sole screen; anything else + /// routes only on the fills-screen proof. + func testSoleScreenRouteOpensOnlyOnProvedFullScreenOffThePhoneIdiom() { + for evidence: RunnerFullScreenEvidence in [ + .fillsScreen, + .notPrimaryApp, + .windowed, + .unknown, + ] { + XCTAssertTrue( + RunnerSoleScreenRouteOpensForEvidence(true, evidence), + "an iPhone keeps every app full-screen whatever the probe reports" + ) + } + + XCTAssertTrue(RunnerSoleScreenRouteOpensForEvidence(false, .fillsScreen)) + XCTAssertFalse( + RunnerSoleScreenRouteOpensForEvidence(false, .windowed), + "a windowed iPad app must never book a gesture on the screen frame" + ) + XCTAssertFalse(RunnerSoleScreenRouteOpensForEvidence(false, .notPrimaryApp)) + XCTAssertFalse( + RunnerSoleScreenRouteOpensForEvidence(false, .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/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index 0d7d1c78c3..9cc8aefed9 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -722,6 +722,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 XCTest fallback is not correct for a windowed app either. The runner clips window-relative element frames against the screen-relative app frame. 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..9fd639f6f5 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 a single-screen device where the app provably fills that screen, an unreadable app tree can use a top-leading tap 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/website/docs/docs/commands.md b/website/docs/docs/commands.md index bdf8992459..1d64d9366c 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 a single-screen device where the app provably fills that 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`. From 17ae6b1be7b1c7f93011807927a3179619dc21c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 24 Oct 2026 09:52:31 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(ios-runner):=20resolve=20the=20full-scr?= =?UTF-8?q?een=20review=20=E2=80=94=20one=20fallback=20formula,=20one=20ru?= =?UTF-8?q?le=20block,=20one=20nightly=20job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the frame.minY > 1 branch so one inset-based formula serves every back route (a minY+22 point on a windowed iPad is the window-controls strip). - Set the probe's loggedFills flag only in the fills-screen case so a first windowed answer cannot hide the line the nightly grep reads. - Share the foreground-ownership and frame-fill rule between the runner bridge and the snapshot bridge as one byte-identical block, pinned equal by test/repo-guards/full-screen-rule-parity.test.ts; delete both copies. - Early-return on the phone idiom so RunnerFullScreenEvidenceUnknown keeps one meaning. - Reuse the nightly full-suite job for the iPad leg via its matrix instead of a parallel job; rename the back fixture test to what it now proves and delete the stale iPad-refuses doc lines. --- .github/workflows/xctest-nightly.yml | 155 +++++++----------- .../AgentDeviceRunner/AgentDeviceRunnerApp.m | 18 +- .../RunnerTests+Navigation.swift | 12 +- .../RunnerXCTestEventBridge.m | 116 +++++++------ .../RunnerTests+NavigationTests.swift | 22 +-- ...nerTests+SynthesizedInteractionTests.swift | 3 - apple/snapshot-bridge/SnapshotBridgeRuntime.m | 56 +++++-- .../full-screen-rule-parity.test.ts | 40 +++++ 8 files changed, 225 insertions(+), 197 deletions(-) create mode 100644 test/repo-guards/full-screen-rule-parity.test.ts diff --git a/.github/workflows/xctest-nightly.yml b/.github/workflows/xctest-nightly.yml index 4ceb750bcd..83e96f4478 100644 --- a/.github/workflows/xctest-nightly.yml +++ b/.github/workflows/xctest-nightly.yml @@ -20,9 +20,25 @@ 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: iPhone full suite + device-name: iPhone 17 Pro + lane: nightly + - name: iPad sole-screen evidence + device-name: iPad Pro 11-inch (M4) + 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 +54,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 +83,7 @@ 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 }} # 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 +100,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 +119,45 @@ jobs: } ' + # The iPad leg's liveness check is its own grep below: it runs a named selection, not the + # whole bundle, so the count assertion the full-suite leg makes does not apply to it. The + # boot action falls back to any simulator when the preferred name is missing, so this step + # proves it booted the iPad that leg claims — the evidence line can only come from one. + - name: Run the sole-screen tests on iPad + if: matrix.leg.lane == 'nightly-ipad' + run: | + set -euo pipefail + UDID='${{ steps.ios-simulator.outputs.simulator-udid }}' + xcrun simctl list devices -j | node -e ' + const fs = require("node:fs"); + const udid = process.argv[1]; + const payload = JSON.parse(fs.readFileSync(0, "utf8")); + const device = Object.values(payload.devices ?? {}) + .flat() + .find((entry) => entry.udid === udid); + if (!device?.name?.startsWith("iPad")) { + console.error(`Booted ${device?.name ?? udid}; this leg needs an iPad simulator`); + process.exit(1); + } + ' "$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/testRankedInAppBackUsesSynthesisWithoutXCTestTap \ + 2>&1 | tee .tmp/xctest-nightly/runner.log + # 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 +182,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,96 +198,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 - - # The full suite above runs on an iPhone, where the sole-screen rule is decided by the idiom - # alone. The iPad half of the rule — the route opens only while the in-process full-screen - # evidence proves the app fills its screen — can only execute on an iPad. This leg boots one, - # runs the sole-screen tests, and asserts the evidence line the probe prints: no other lane can - # emit it, so a revert to idiom-only routing turns this red instead of silently green. - ipad-sole-screen: - name: iOS Runner Sole-Screen Evidence (iPad) - runs-on: macos-26 - timeout-minutes: 30 - env: - # Byte-identical inputs to the job above, so both restore the same runner cache entry. - IOS_RUNTIME_VERSION: '26.2' - AGENT_DEVICE_XCUITEST_ARCHS: 'arm64' - AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS: '1' - AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup toolchain - uses: ./.github/actions/setup-node-pnpm - with: - cache-dependency-path: | - pnpm-lock.yaml - examples/test-app/pnpm-lock.yaml - - - name: Restore and build iOS XCTest runner - uses: ./.github/actions/setup-apple-runner-build - with: - derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }} - cache-key-prefix: ios-runner-prebuilt - cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}-arm64 - gate: swift-runner-ios - xcuitest-platform: ios - xcuitest-destination: generic/platform=iOS Simulator - - - name: Boot iPad test simulator - id: ipad-simulator - uses: ./.github/actions/boot-ios-test-simulator - with: - runtime-version: ${{ env.IOS_RUNTIME_VERSION }} - preferred-device-name: iPad Pro 11-inch (M4) - - - name: Run the sole-screen tests on iPad - run: | - set -euo pipefail - UDID='${{ steps.ipad-simulator.outputs.simulator-udid }}' - # The boot action falls back to any simulator when the preferred name is missing. This - # leg proves an iPad-only rule, so a non-iPad fallback must fail here, not at the grep. - xcrun simctl list devices -j | node -e ' - const fs = require("node:fs"); - const udid = process.argv[1]; - const payload = JSON.parse(fs.readFileSync(0, "utf8")); - const device = Object.values(payload.devices ?? {}) - .flat() - .find((entry) => entry.udid === udid); - if (!device?.name?.startsWith("iPad")) { - console.error(`Booted ${device?.name ?? udid}; this leg needs an iPad simulator`); - process.exit(1); - } - ' "$UDID" - XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)" - test -n "$XCTESTRUN_PATH" - mkdir -p .tmp/xctest-nightly-ipad - xcodebuild test-without-building \ - -xctestrun "$XCTESTRUN_PATH" \ - -destination "platform=iOS Simulator,id=${{ steps.ipad-simulator.outputs.simulator-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/testInAppBackStillAsksForWindowsWhereNoSoleScreenAnswers \ - -only-testing:AgentDeviceRunnerUITests/RunnerTests/testRankedInAppBackUsesSynthesisWithoutXCTestTap \ - 2>&1 | tee .tmp/xctest-nightly-ipad/runner.log - # 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-ipad/runner.log \ - || { echo 'The iPad sole-screen route never produced full-screen evidence.'; exit 1; } - - - name: Upload iPad log - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: xctest-nightly-ipad-${{ github.run_id }}-${{ github.run_attempt }} - include-hidden-files: true - path: .tmp/xctest-nightly-ipad - if-no-files-found: warn diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m index 2eebadf23f..47b6c97cf5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m @@ -666,25 +666,15 @@ - (void)viewDidLoad { self.navigationBackStatus = label; label.accessibilityIdentifier = @"agent-device-back-status"; if ([NSProcessInfo.processInfo.arguments containsObject:@"--navigation-bar"]) { - // Same band rule as the plain fixture below: the bar sits under the screen's own top inset, - // which differs by family (59 pt on an iPhone 17 Pro, 32 pt on an iPad Pro), so the bar - // anchors to the safe-area top instead of a hardcoded iPhone y. - UINavigationBar *bar = [[UINavigationBar alloc] init]; - bar.translatesAutoresizingMaskIntoConstraints = NO; + UINavigationBar *bar = [[UINavigationBar alloc] initWithFrame:CGRectMake(0, 62, self.view.bounds.size.width, 44)]; UINavigationItem *item = [[UINavigationItem alloc] initWithTitle:@"Thread"]; item.leftBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"Go back" style:UIBarButtonItemStylePlain target:self action:@selector(navigationBackPressed)]; bar.items = @[item]; [self.view addSubview:bar]; - [NSLayoutConstraint activateConstraints:@[ - [bar.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor], - [bar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], - [bar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], - [bar.heightAnchor constraintEqualToConstant:44], - ]]; } else { - // The bar band sits under the screen's own top inset, which differs by family (59 pt on an - // iPhone 17 Pro, 32 pt on an iPad Pro). Pinning the control to the safe-area top keeps this - // fixture inside the band the fallback aims at, on either idiom. + // The control sits in the top bar band, whose height above the content differs by family + // (59 pt on an iPhone 17 Pro, 32 pt on an iPad Pro), so it anchors to the safe-area top + // instead of a hardcoded iPhone y. UIButton *back = [UIButton buttonWithType:UIButtonTypeSystem]; back.translatesAutoresizingMaskIntoConstraints = NO; [back setTitle:@"Go back" forState:UIControlStateNormal]; diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift index 34721e3773..2d811049fe 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift @@ -183,11 +183,8 @@ extension RunnerTests { /// 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 screen's inset counts - /// only when the frame starts at the screen's own top (the sole-screen route and a maximized - /// window); a window that starts below the screen top places its bar at its own top instead. The - /// bounds keep a runtime that reports no inset inside a plausible bar, and a large inset from - /// leaving it. + /// 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 @@ -197,11 +194,6 @@ extension RunnerTests { } // Aim at the standard leading navigation slot, bounded for compact and tablet widths. let xOffset = min(max(frame.width * 0.08, 28), 44) - // A screen-top frame takes the screen's inset and the band bounds; a window that starts lower - // places its bar at its own top, so the point centers a standard bar there instead. - if frame.minY > 1 { - return CGPoint(x: frame.minX + xOffset, y: frame.minY + 22) - } let yOffset = min(max(topSafeAreaInset + 22, 56), 96) return CGPoint(x: frame.minX + xOffset, y: frame.minY + yOffset) } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m index e1a03d47c2..f6be7171a4 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m @@ -10,10 +10,44 @@ #import #endif -// How far apart two screen-native frames may be and still name the same rectangle. The Simulator -// snapshot bridge refuses a windowed capture on the same tolerance (#3844). The two targets compile -// independently; this constant's owner is the runner's synthesized-gesture route. -static const CGFloat RunnerFullScreenFrameTolerance = 0.5; +// AGENT_DEVICE_FULL_SCREEN_RULE_BEGIN +// One rule, two targets, one block. 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. Both read the same two +// frames from the same private AX runtime in one native space. The two binaries build from +// disjoint source roots in the checkout and in a packaged install, so no include path reaches both +// builds; `test/repo-guards/full-screen-rule-parity.test.ts` pins the two copies of this block +// byte-identical. Each caller keeps its own policy for what a missing read proves; this block +// states only what the runtime reports. + +// The AXRuntime class both targets reach without walking the app's accessibility tree, so both +// answer on a tree XCTest cannot serialize. +@protocol AgentDeviceForegroundElement ++ (nullable id)primaryApp; ++ (nullable id)systemApplication; +- (pid_t)pid; +- (CGRect)frame; +@end + +// Whether the accessibility primary app is the process this capture or gesture belongs to. 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; +} + +// How far apart two screen-native frames may be and still name the same rectangle. Sub-pixel +// layout rounding is not a window; anything past this is a window that shows the app in part of +// the screen (Windowed Apps or Stage Manager). +static const CGFloat AgentDeviceFullScreenFrameTolerance = 0.5; + +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, @@ -231,13 +265,6 @@ BOOL RunnerResolveApplicationScreen( return RunnerResolveSoleScreen(application, &screen, displayID); } -static BOOL RunnerFramesAgree(CGRect window, CGRect screen) { - return fabs(window.origin.x - screen.origin.x) <= RunnerFullScreenFrameTolerance && - fabs(window.origin.y - screen.origin.y) <= RunnerFullScreenFrameTolerance && - fabs(window.size.width - screen.size.width) <= RunnerFullScreenFrameTolerance && - fabs(window.size.height - screen.size.height) <= RunnerFullScreenFrameTolerance; -} - RunnerFullScreenEvidence RunnerFullScreenEvidenceFromForeground( pid_t targetProcessID, pid_t primaryProcessID, @@ -249,16 +276,14 @@ RunnerFullScreenEvidence RunnerFullScreenEvidenceFromForeground( if (targetProcessID <= 0 || primaryProcessID <= 0) { return RunnerFullScreenEvidenceUnknown; } - // The screen to fill is the one the target app owns. A bridge capture is refused the same way when - // a system surface owns the foreground (ADR 0004, foreground ownership). - if (primaryProcessID != targetProcessID) { + if (!AgentDeviceForegroundOwnershipMatches(primaryProcessID, targetProcessID)) { return RunnerFullScreenEvidenceNotPrimaryApp; } if (CGRectIsNull(appFrame) || CGRectIsNull(screenFrame) || CGRectIsEmpty(appFrame) || CGRectIsEmpty(screenFrame)) { return RunnerFullScreenEvidenceUnknown; } - return RunnerFramesAgree(appFrame, screenFrame) + return AgentDeviceForegroundWindowFillsScreen(appFrame, screenFrame) ? RunnerFullScreenEvidenceFillsScreen : RunnerFullScreenEvidenceWindowed; } @@ -277,25 +302,17 @@ BOOL RunnerSoleScreenRouteOpensForEvidence( // 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. Neither read walks the app's accessibility tree, so both answer on a tree -// XCTest cannot serialize. +// 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"; -@protocol RunnerForegroundElement -+ (nullable id)primaryApp; -+ (nullable id)systemApplication; -- (pid_t)pid; -- (CGRect)frame; -@end - -static Class _Nullable RunnerForegroundElementClass(void) { +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; + static Class _Nullable cached = Nil; if (!resolved) { Class element = objc_lookUpClass("AXElement"); if (element == Nil) { @@ -304,27 +321,27 @@ - (CGRect)frame; dlopen(RunnerXCTAutomationSupportPath.UTF8String, RTLD_NOW); element = objc_lookUpClass("AXElement"); } - cached = (Class)element; + cached = (Class)element; resolved = YES; } return cached; } -static id _Nullable RunnerForegroundElement( - Class element, +static id _Nullable RunnerForegroundElement( + Class element, SEL selector ) { if (![element respondsToSelector:selector]) { return nil; } - __block id value = 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) { +static CGRect RunnerForegroundElementFrame(id element) { if (![element respondsToSelector:@selector(frame)]) { return CGRectNull; } @@ -337,7 +354,7 @@ static CGRect RunnerForegroundElementFrame(id element) // 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) { +static pid_t RunnerForegroundElementPid(id element) { if (![element respondsToSelector:@selector(pid)]) { return 0; } @@ -352,13 +369,13 @@ static RunnerFullScreenEvidence RunnerProbeApplicationFillsScreen(id application if (application == nil) { return RunnerFullScreenEvidenceUnknown; } - Class element = RunnerForegroundElementClass(); + Class element = RunnerForegroundElementClass(); if (element == Nil) { return RunnerFullScreenEvidenceUnknown; } - id primaryApp = + id primaryApp = RunnerForegroundElement(element, @selector(primaryApp)); - id systemApp = + id systemApp = RunnerForegroundElement(element, @selector(systemApplication)); if (primaryApp == nil || systemApp == nil) { return RunnerFullScreenEvidenceUnknown; @@ -384,10 +401,15 @@ static RunnerFullScreenEvidence RunnerProbeApplicationFillsScreen(id application 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) { - loggedFills = YES; - NSLog( + 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" @@ -396,7 +418,6 @@ static RunnerFullScreenEvidence RunnerProbeApplicationFillsScreen(id application 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; } @@ -409,15 +430,14 @@ static RunnerFullScreenEvidence RunnerProbeApplicationFillsScreen(id application ) { *screen = nil; #if TARGET_OS_IOS - const BOOL isPhoneIdiom = - UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone; - // The probe walks the AX bridge; an iPhone's idiom already decides the rule, so it never pays - // for the read. Unknown on the phone side records "not probed", and the rule still opens. - const RunnerFullScreenEvidence evidence = isPhoneIdiom - ? RunnerFullScreenEvidenceUnknown - : RunnerProbeApplicationFillsScreen(application); - if (!RunnerSoleScreenRouteOpensForEvidence(isPhoneIdiom, evidence)) { - return @"private XCTest event synthesis unavailable: no resolved application window, and the sole screen is not provably the app's full-screen frame"; + // 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) { + const RunnerFullScreenEvidence evidence = RunnerProbeApplicationFillsScreen(application); + if (!RunnerSoleScreenRouteOpensForEvidence(NO, 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"; diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift index 1bcd8f3dad..a49dbac03d 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift @@ -50,13 +50,13 @@ extension RunnerTests { } } - /// Only an app that provably fills its sole 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. - /// The runner host app fills its simulator's screen on both families, so this negative needs a - /// route the device cannot open: SpringBoard's app is not the accessibility primary app on an - /// iPad, and on an iPhone the idiom alone opens the route, where the expectation is the iPhone's. + /// 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 — a device where no route opens — is the negative in + /// `testSoleScreenDisplayIDIsNamedOnlyForAnAppThatFillsItsScreen`, which no simulator here can + /// reach because its foreground app always fills its screen. @MainActor - func testInAppBackStillAsksForWindowsWhereNoSoleScreenAnswers() throws { + func testInAppBackAfterARejectedQueryTapsThroughTheRouteThatOpens() throws { let status = try launchBackFixture(["--agent-device-back-regression"]) defer { endBackFixture() } @@ -214,21 +214,13 @@ extension RunnerTests { /// 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. A window that starts below the screen's top (a resolvable - /// Stage Manager window) places its bar at its own top, so the screen's inset must not shift it - /// further down. + /// 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") - - let loweredWindow = CGRect(x: 120, y: 130, width: 600, height: 950) - let lowered = try XCTUnwrap( - Self.topLeadingNavigationFallbackPoint(in: loweredWindow, topSafeAreaInset: 32) - ) - XCTAssertEqual(lowered.y, 152, accuracy: 0.01, "the bar's middle sits at the window's own top plus half a standard bar") } func testNavigationBackControlRankPrefersBackThenCloseThenCancel() { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift index f2b0245ee1..1e695c6ee5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift @@ -157,9 +157,6 @@ 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. /// 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 diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.m b/apple/snapshot-bridge/SnapshotBridgeRuntime.m index c59949077b..469a3aebee 100644 --- a/apple/snapshot-bridge/SnapshotBridgeRuntime.m +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.m @@ -67,13 +67,45 @@ @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, one block. 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. Both read the same two +// frames from the same private AX runtime in one native space. The two binaries build from +// disjoint source roots in the checkout and in a packaged install, so no include path reaches both +// builds; `test/repo-guards/full-screen-rule-parity.test.ts` pins the two copies of this block +// byte-identical. Each caller keeps its own policy for what a missing read proves; this block +// states only what the runtime reports. + +// The AXRuntime class both targets reach without walking the app's accessibility tree, so both +// answer on a tree XCTest cannot serialize. +@protocol AgentDeviceForegroundElement ++ (nullable id)primaryApp; ++ (nullable id)systemApplication; - (pid_t)pid; - (CGRect)frame; @end +// Whether the accessibility primary app is the process this capture or gesture belongs to. 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; +} + +// How far apart two screen-native frames may be and still name the same rectangle. Sub-pixel +// layout rounding is not a window; anything past this is a window that shows the app in part of +// the screen (Windowed Apps or Stage Manager). +static const CGFloat AgentDeviceFullScreenFrameTolerance = 0.5; + +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 +215,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 +228,20 @@ - (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; + return !AgentDeviceForegroundWindowFillsScreen(window, screen); } - (nullable id)jsonValue:(id)value name:(NSString *)name 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..46a2b9f2d7 --- /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 '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); +} From f4944b5c9af7ef6fff1ac03f57e99312bb82caf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 24 Oct 2026 09:57:58 +0200 Subject: [PATCH 3/3] style(ios-runner): node: imports in the parity guard; trim the shared rule block Fixes the lint failure at 17ae6b1 and cuts the pinned block to the rule its two callers actually share, which is the size the review asked for. --- .../RunnerXCTestEventBridge.m | 27 +++++++------------ apple/snapshot-bridge/SnapshotBridgeRuntime.m | 27 +++++++------------ .../full-screen-rule-parity.test.ts | 2 +- 3 files changed, 21 insertions(+), 35 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m index f6be7171a4..35e35406a3 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m @@ -11,17 +11,12 @@ #endif // AGENT_DEVICE_FULL_SCREEN_RULE_BEGIN -// One rule, two targets, one block. 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. Both read the same two -// frames from the same private AX runtime in one native space. The two binaries build from -// disjoint source roots in the checkout and in a packaged install, so no include path reaches both -// builds; `test/repo-guards/full-screen-rule-parity.test.ts` pins the two copies of this block -// byte-identical. Each caller keeps its own policy for what a missing read proves; this block -// states only what the runtime reports. - -// The AXRuntime class both targets reach without walking the app's accessibility tree, so both -// answer on a tree XCTest cannot serialize. +// 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; @@ -29,16 +24,14 @@ - (pid_t)pid; - (CGRect)frame; @end -// Whether the accessibility primary app is the process this capture or gesture belongs to. 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). +// 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; } -// How far apart two screen-native frames may be and still name the same rectangle. Sub-pixel -// layout rounding is not a window; anything past this is a window that shows the app in part of -// the screen (Windowed Apps or Stage Manager). +// 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; static BOOL AgentDeviceForegroundWindowFillsScreen(CGRect window, CGRect screen) { diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.m b/apple/snapshot-bridge/SnapshotBridgeRuntime.m index 469a3aebee..53ccda143b 100644 --- a/apple/snapshot-bridge/SnapshotBridgeRuntime.m +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.m @@ -68,17 +68,12 @@ + (nullable XCAccessibilityElement *)elementWithProcessIdentifier:(pid_t)pid; @end // AGENT_DEVICE_FULL_SCREEN_RULE_BEGIN -// One rule, two targets, one block. 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. Both read the same two -// frames from the same private AX runtime in one native space. The two binaries build from -// disjoint source roots in the checkout and in a packaged install, so no include path reaches both -// builds; `test/repo-guards/full-screen-rule-parity.test.ts` pins the two copies of this block -// byte-identical. Each caller keeps its own policy for what a missing read proves; this block -// states only what the runtime reports. - -// The AXRuntime class both targets reach without walking the app's accessibility tree, so both -// answer on a tree XCTest cannot serialize. +// 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; @@ -86,16 +81,14 @@ - (pid_t)pid; - (CGRect)frame; @end -// Whether the accessibility primary app is the process this capture or gesture belongs to. 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). +// 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; } -// How far apart two screen-native frames may be and still name the same rectangle. Sub-pixel -// layout rounding is not a window; anything past this is a window that shows the app in part of -// the screen (Windowed Apps or Stage Manager). +// 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; static BOOL AgentDeviceForegroundWindowFillsScreen(CGRect window, CGRect screen) { diff --git a/test/repo-guards/full-screen-rule-parity.test.ts b/test/repo-guards/full-screen-rule-parity.test.ts index 46a2b9f2d7..081cceb98b 100644 --- a/test/repo-guards/full-screen-rule-parity.test.ts +++ b/test/repo-guards/full-screen-rule-parity.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import path from 'path'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { test } from 'vitest';