diff --git a/.github/actions/setup-apple-runner-build/action.yml b/.github/actions/setup-apple-runner-build/action.yml index e637ad3bc2..09514d9450 100644 --- a/.github/actions/setup-apple-runner-build/action.yml +++ b/.github/actions/setup-apple-runner-build/action.yml @@ -42,7 +42,7 @@ runs: id: source-hash run: | set -euo pipefail - echo "value=${{ hashFiles('apple/runner/**', 'apple/snapshot-presentation/**') }}" >> "$GITHUB_OUTPUT" + echo "value=${{ hashFiles('apple/runner/**', 'apple/snapshot-presentation/**', 'apple/snapshot-bridge/ForegroundApplicationGeometry.h') }}" >> "$GITHUB_OUTPUT" shell: bash - name: Resolve Apple runner cache schema diff --git a/.github/workflows/xctest-nightly.yml b/.github/workflows/xctest-nightly.yml index 74f1299f36..c1d2e679ad 100644 --- a/.github/workflows/xctest-nightly.yml +++ b/.github/workflows/xctest-nightly.yml @@ -20,9 +20,30 @@ concurrency: jobs: full-suite: - name: iOS Runner Full XCTest Suite + name: iOS Runner Full XCTest Suite (${{ matrix.leg.name }}) runs-on: macos-26 timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + leg: + # Both legs share the toolchain, the runner build cache, and the artifact steps above. + # The full suite runs on an iPhone, where the sole-screen rule is decided by the idiom + # alone. The iPad leg boots an iPad and runs only the sole-screen tests: the iPad half + # of the rule — the route opens only while the in-process full-screen evidence proves + # the app fills its screen — can execute nowhere else, and the evidence line that probe + # prints is what turns a revert to idiom-only routing red instead of silently green. + # `name-prefix` is what each leg asserts the booted simulator's name starts with: the + # boot action falls back to any simulator when the preferred name is missing, so a + # renamed or absent device model must fail the leg that depends on its idiom. + - name: iPhone full suite + device-name: iPhone 17 Pro + name-prefix: iPhone + lane: nightly + - name: iPad sole-screen evidence + device-name: iPad Pro 11-inch (M5) + name-prefix: iPad + lane: nightly-ipad env: # Must match apple.yml. The runtime version rides in the derived-data cache key, and # AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS is BOTH an input to the cache's build-variant @@ -38,7 +59,7 @@ jobs: AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived RESULT_BUNDLE_PATH: ${{ github.workspace }}/.tmp/xctest-nightly/RunnerTests.xcresult RESULT_SUMMARY_PATH: ${{ github.workspace }}/.tmp/xctest-nightly/test-results-summary.json - XCTEST_LANE: nightly + XCTEST_LANE: ${{ matrix.leg.lane }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -67,7 +88,31 @@ jobs: uses: ./.github/actions/boot-ios-test-simulator with: runtime-version: ${{ env.IOS_RUNTIME_VERSION }} - preferred-device-name: iPhone 17 Pro + preferred-device-name: ${{ matrix.leg.device-name }} + + # The boot action falls back to any simulator when the preferred name is missing, so each + # leg proves it booted the family it claims: the full-suite leg's coverage is iPhone-only at + # the idiom, and the iPad leg's evidence line can only come from an iPad. A renamed or + # removed device model on the image fails here instead of quietly testing the wrong idiom. + - name: Assert the booted simulator's family + run: | + set -euo pipefail + node -e ' + const { execFileSync } = require("node:child_process"); + const [udid, prefix] = process.argv.slice(1); + const payload = JSON.parse( + execFileSync("xcrun", ["simctl", "list", "devices", "-j"], { maxBuffer: 64 * 1024 * 1024 }).toString(), + ); + const device = Object.values(payload.devices ?? {}) + .flat() + .find((entry) => entry.udid === udid); + if (!device?.name?.startsWith(prefix)) { + console.error(`Booted ${device?.name ?? udid}; this leg needs a simulator named "${prefix}…"`); + process.exit(1); + } + console.log(`Booted ${device.name}`); + ' '${{ steps.ios-simulator.outputs.simulator-udid }}' '${{ matrix.leg.name-prefix }}' + shell: bash # Same command as apple.yml's targeted step minus every `-only-testing:` flag, so the # xctestrun's own test plan decides what runs — with one exception. @@ -84,6 +129,7 @@ jobs: # A typo in that identifier silently re-arms the hang, so # `pnpm check:xctest-selection` validates `-skip-testing:` exactly like `-only-testing:`. - name: Run the full iOS runner XCTest suite + if: matrix.leg.lane == 'nightly' run: | set -euo pipefail XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)" @@ -102,6 +148,31 @@ jobs: } ' + # The iPad leg runs a named selection: the smallest set that can execute the off-phone half of + # the sole-screen rule — the admission boundary itself, one hostile-tree command that must + # reach synthesis, and `back`'s route witness while the fallback's inset source is changing. + # Geometry and error cases live in the host-runnable evidence matrix instead. `xcodebuild` + # exits 0 when a selection matches nothing, so the reporter below asserts the executed count + # against this lane's reach (`XCTEST_LANE=nightly-ipad`, derived by check:xctest-selection). + - name: Run the sole-screen tests on iPad + if: matrix.leg.lane == 'nightly-ipad' + run: | + set -euo pipefail + # This leg skips the full-suite step that creates this directory for the iPhone leg, so + # it must create it in its own leg before `tee` writes into it. + mkdir -p .tmp/xctest-nightly + UDID='${{ steps.ios-simulator.outputs.simulator-udid }}' + XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)" + test -n "$XCTESTRUN_PATH" + xcodebuild test-without-building \ + -xctestrun "$XCTESTRUN_PATH" \ + -destination "platform=iOS Simulator,id=$UDID" \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSoleScreenDisplayIDIsNamedOnlyForAnAppThatFillsItsScreen \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testLongPressOnAnUnserializableTreeIsSynthesizedOnTheSoleScreen \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testInAppBackAfterARejectedQueryTapsThroughTheRouteThatOpens \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testInAppBackAfterARejectedQueryWithUnprovableForegroundTapsThroughTheWindow \ + -resultBundlePath "$RESULT_BUNDLE_PATH" 2>&1 | tee .tmp/xctest-nightly/runner.log + # 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. @@ -122,8 +193,9 @@ jobs: # build variant without the unit-test compile flag, an empty test plan, or a renamed # target would all read as a healthy night. The reporter asserts the executed count # equals what `pnpm check:xctest-selection` derives this lane reaches from the `#if` - # guards (XCTEST_LANE above), so a guard that compiles a file of tests out of iOS reads - # as red too. The failure list is capped so a badly red night cannot blow the 1 MiB + # guards and this workflow's own flags (XCTEST_LANE above), so a guard that compiles a + # file of tests out of iOS, or a selection whose name no longer matches, reads 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() @@ -142,7 +214,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: xctest-nightly-results-${{ github.run_id }}-${{ github.run_attempt }} + name: xctest-nightly-${{ matrix.leg.lane }}-${{ github.run_id }}-${{ github.run_attempt }} include-hidden-files: true path: .tmp/xctest-nightly if-no-files-found: warn diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj b/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj index f6c7706c48..89b7ac51a3 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj @@ -436,6 +436,14 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 2S799L9W4M; + // The shared full-screen rule lives in apple/snapshot-bridge beside the runner + // project. The first path resolves it in a checkout; the second resolves it in + // an installed npm package, where the project sits under dist/ and the header + // ships at the package root. The unused entry is inert. + HEADER_SEARCH_PATHS = ( + "$(SRCROOT)/../../snapshot-bridge", + "$(SRCROOT)/../../../../apple/snapshot-bridge", + ); GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 13.0; @@ -462,6 +470,11 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 2S799L9W4M; + // Keep HEADER_SEARCH_PATHS in step with the Debug configuration above. + HEADER_SEARCH_PATHS = ( + "$(SRCROOT)/../../snapshot-bridge", + "$(SRCROOT)/../../../../apple/snapshot-bridge", + ); GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 13.0; diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerAppFixtures.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerAppFixtures.m index 22a033ee9f..2cdc8ed63d 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerAppFixtures.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerAppFixtures.m @@ -621,11 +621,23 @@ - (void)viewDidLoad { bar.items = @[item]; [self.view addSubview:bar]; } else { + // The control must sit under the point the runner's top-leading fallback aims at, whose + // bounds are known: x lands at most 44 from the frame's leading edge, y at + // clamp(safeTopInset + 22, 56, 96). Anchoring to the view's leading (not the safe-area's) + // keeps the button under x=44 on a landscape iPhone, where the side inset would otherwise + // start a safe-area anchor past it; the safe-area top plus 12 with a 64-pt height spans the + // whole clamped y range for every inset an iPhone or iPad reports. UIButton *back = [UIButton buttonWithType:UIButtonTypeSystem]; - back.frame = CGRectMake(12, 62, 88, 44); + back.translatesAutoresizingMaskIntoConstraints = NO; [back setTitle:@"Go back" forState:UIControlStateNormal]; [back addTarget:self action:@selector(navigationBackPressed) forControlEvents:UIControlEventTouchUpInside]; [self.view addSubview:back]; + [NSLayoutConstraint activateConstraints:@[ + [back.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:12], + [back.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor constant:12], + [back.widthAnchor constraintEqualToConstant:88], + [back.heightAnchor constraintEqualToConstant:64], + ]]; } } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift index 952a934d2b..c71fde7267 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift @@ -133,11 +133,12 @@ extension RunnerTests { } /// Samples the same sole-screen route that synthesized gestures use when the app tree refuses - /// its window. This route cannot name a screen on an iPad or a multi-screen iPhone. - func captureSoleAppScreen() -> Result { + /// its window. On an iPad the route opens only while the app provably fills its sole screen, so + /// a windowed app keeps refusing instead of sampling the wrong frame. + func captureSoleAppScreen(app: XCUIApplication) -> Result { var screen: AnyObject? var displayID: UInt = 0 - guard RunnerResolveSoleScreen(&screen, &displayID) == nil, + guard RunnerResolveSoleScreen(app, &screen, &displayID) == nil, let captureScreen = screen as? XCUIScreen else { return .failure(.unresolvedScreen) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m index 7aa7d031c9..8074550cba 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m @@ -333,7 +333,7 @@ static RunnerTapSynthesisStatus RunnerTrySynthesizeTap( NSUInteger displayID = 0; // A nil window is the caller's sole-screen route: the app tree could not name its window. NSString *displayError = resolvedWindow == nil - ? RunnerResolveSoleScreenDisplayID(&displayID) + ? RunnerResolveSoleScreenDisplayID(application, &displayID) : RunnerResolveWindowDisplayID(resolvedWindow, &displayID); if (displayError != nil) return displayError; NSInteger interfaceOrientation = diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift index a0d888c33f..2d811049fe 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift @@ -179,18 +179,44 @@ extension RunnerTests { return candidate.midY >= window.minY && candidate.midY <= maxY } - static func topLeadingNavigationFallbackPoint(in frame: CGRect) -> CGPoint? { + /// The point the top-leading `back` fallback aims at: the leading slot of the bar that sits + /// directly under the top inset of the frame the tap lands in. A fraction of the frame height + /// cannot serve both families — an iPhone 17 Pro measures a 59-pt inset with its bar at y 62-116, + /// while an iPad Pro 11" measures 32 with its bar at y 32-86, where the iPhone's height-derived 96 + /// lands below the bar. `22` is half of UIKit's standard 44-pt top bar. The bounds keep a runtime + /// that reports no inset inside a plausible bar, and a large inset from leaving it. + static func topLeadingNavigationFallbackPoint( + in frame: CGRect, + topSafeAreaInset: CGFloat + ) -> CGPoint? { guard SnapshotGeometry.isPositiveFinite(frame) else { return nil } // Aim at the standard leading navigation slot, bounded for compact and tablet widths. let xOffset = min(max(frame.width * 0.08, 28), 44) - // Sit below the status/dynamic-island region, inside the bar's leading control: on a 402x874 - // iPhone the bar spans y 62-116 and its back control 62-106. - let yOffset = min(max(frame.height * 0.1, 56), 96) + let yOffset = min(max(topSafeAreaInset + 22, 56), 96) return CGPoint(x: frame.minX + xOffset, y: frame.minY + yOffset) } + /// The screen's top inset, read in-process from the runner's own window scene. The status-bar / + /// Dynamic Island band is a screen-level fact, so the runner's full-screen host window reports the + /// one the target app sees — and unlike any element frame it answers on a tree XCTest cannot + /// serialize. The largest top inset among the scene's windows wins: an alert or keyboard window + /// reports none, and the window that carries the band is the one to ask. + private func screenTopSafeAreaInset() -> CGFloat { + #if os(iOS) + let insets = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .map(\.safeAreaInsets.top) + let inset = insets.max() ?? 0 + NSLog("AGENT_DEVICE_RUNNER_BACK_FALLBACK_TOP_INSET read=%.1f windows=%zd", inset, insets.count) + return inset + #else + return 0 + #endif + } + @MainActor private func tapTopLeadingNavigationFallback( app: XCUIApplication, @@ -201,7 +227,10 @@ extension RunnerTests { app: app, policy: synthesizedGesturePolicy(.coordinateTap), windowQueryRejected: windowQueryRejected - ), let point = Self.topLeadingNavigationFallbackPoint(in: context.referenceFrame) else { + ), let point = Self.topLeadingNavigationFallbackPoint( + in: context.referenceFrame, + topSafeAreaInset: screenTopSafeAreaInset() + ) else { return .unavailable } let before = captureNavigationFallbackVisualState(app: app, context: context) @@ -238,7 +267,7 @@ extension RunnerTests { return Self.navigationFallbackSample( resolvingApp: { context.resolvedWindow == nil - ? self.captureSoleAppScreen() + ? self.captureSoleAppScreen(app: app) : self.captureResolvedAppScreen(app: app) }, systemSurface: { self.captureResolvedAppScreen(app: self.springboard) }, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift index d6d6d9e9b4..bcbaf43e14 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift @@ -343,8 +343,8 @@ extension RunnerTests { } /// The viewport the portable gesture planner lays pointer paths in: the frame of the synthesized - /// context its record is routed through, so a tree with no window on an iPhone with one screen - /// plans on that screen, the same route a synthesized tap or scroll takes. + /// context its record is routed through, so a tree with no window whose app provably fills its + /// sole screen plans on that screen, the same route a synthesized tap or scroll takes. @MainActor func gestureViewportFrame(app: XCUIApplication) -> CGRect { #if os(iOS) @@ -518,14 +518,18 @@ extension RunnerTests { #if os(iOS) - /// The reference frame of the sole-screen route, read without the app's accessibility tree: on an - /// iPhone with one screen a full-screen app's window is that screen. A deep app tree makes the - /// windows query fail with `kAXErrorIllegalArgument`, and without this route every synthesized - /// tap and scroll on such a screen failed even though synthesis itself needs no tree. Anything - /// that can show an app in part of a screen (iPad, more than one screen) keeps requiring a window. + /// The reference frame of the sole-screen route, read without the app's accessibility tree: when + /// the app provably fills the device's only screen, its window is that screen. A deep app tree + /// makes the windows query fail with `kAXErrorIllegalArgument`, and without this route every + /// synthesized tap and scroll on such a screen failed even though synthesis itself needs no tree. + /// `RunnerResolveSoleScreen` owns which devices prove that — an iPhone always, an iPad only while + /// the app fills the screen — so this reads the same answer the record's routing takes, and + /// anything else (a windowed iPad, a multi-screen foldable) keeps requiring a window. @MainActor func soleScreenReferenceFrame(app: XCUIApplication) -> CGRect? { - guard UIDevice.current.userInterfaceIdiom == .phone else { + var resolvedScreen: AnyObject? + var resolvedDisplayID: UInt = 0 + guard RunnerResolveSoleScreen(app, &resolvedScreen, &resolvedDisplayID) == nil else { return nil } let frame = soleDisplayFrame( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h index 23649dea38..8d3b4dd8d0 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,50 @@ 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 +); + 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..c02720b395 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m @@ -1,10 +1,16 @@ #import "RunnerXCTestEventBridge.h" #import "RunnerObjCExceptionCatcher.h" +#import "RunnerAXSnapshotBridge.h" +// The shared full-screen rule, from the snapshot bridge; found via HEADER_SEARCH_PATHS. +#import "ForegroundApplicationGeometry.h" #import #import #import #if TARGET_OS_IOS #import +#import +#import +#import #endif NSString * _Nullable RunnerResolveXCTestEventBridge( @@ -95,14 +101,6 @@ return [NSString stringWithFormat:@"%@: %@", name, reason]; } -static BOOL RunnerUsableWindowFrame(CGRect frame) { - if (CGRectIsEmpty(frame) || CGRectIsInfinite(frame) || CGRectIsNull(frame)) { - return NO; - } - return isfinite(frame.origin.x) && isfinite(frame.origin.y) - && isfinite(frame.size.width) && isfinite(frame.size.height); -} - static NSString *RunnerApplicationScreenFailureDescription( RunnerApplicationScreenFailure failure ) { @@ -144,7 +142,7 @@ static RunnerApplicationScreenFailure RunnerResolveUsableWindow( readWindow = candidate; }]; *observedFrame = frame; - if (exception != nil || !resolved || !RunnerUsableWindowFrame(frame)) { + if (exception != nil || !resolved || !AgentDeviceForegroundFrameIsMeasurable(frame)) { NSLog( @"AGENT_DEVICE_RUNNER_APP_SCREEN_UNRESOLVED stage=window frame=(%g,%g %gx%g) raised=%@ resolved=%@", frame.origin.x, @@ -218,21 +216,114 @@ 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); +} + +RunnerFullScreenEvidence RunnerFullScreenEvidenceFromForeground( + pid_t targetProcessID, + pid_t primaryProcessID, + CGRect appFrame, + CGRect screenFrame +) { + // A pid that could not be read (or cannot own a process) proves nothing about ownership, so the + // answer is Unknown, never a guess that the app is not the primary app. + if (targetProcessID <= 0 || primaryProcessID <= 0) { + return RunnerFullScreenEvidenceUnknown; + } + if (!AgentDeviceForegroundOwnershipMatches(primaryProcessID, targetProcessID)) { + return RunnerFullScreenEvidenceNotPrimaryApp; + } + // A frame that is not a usable measurement is a runtime that said nothing, not a window that + // differs, so the answer is Unknown, never the Windowed a comparison of the numbers would read as. + if (!AgentDeviceForegroundFramesAreMeasurable(appFrame, screenFrame)) { + return RunnerFullScreenEvidenceUnknown; + } + return AgentDeviceForegroundWindowFillsScreen(appFrame, screenFrame) + ? RunnerFullScreenEvidenceFillsScreen + : RunnerFullScreenEvidenceWindowed; } -NSString * _Nullable RunnerResolveSoleScreen(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"; + +// The private AXElement class the shared protocol describes comes from AXRuntime; the bridge guest +// loads the same pair of private frameworks to reach it. +static NSString *const RunnerAXRuntimePath = + @"/System/Library/PrivateFrameworks/AXRuntime.framework/AXRuntime"; +static NSString *const RunnerXCTAutomationSupportPath = + @"/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework/XCTAutomationSupport"; + +static Class _Nullable RunnerForegroundElementClass(void) { + // Cache the answer including its absence: a runtime without the class would otherwise pay two + // RTLD_NOW lookups per window-less gesture forever. Code availability is the only thing cached + // here — the probe re-measures every attempt, because a resize changes the answer. + static BOOL resolved = NO; + static Class _Nullable cached = Nil; + if (!resolved) { + Class element = objc_lookUpClass("AXElement"); + if (element == Nil) { + dlopen(RunnerAXRuntimePath.UTF8String, RTLD_NOW); + dlopen(RunnerXCTAutomationSupportPath.UTF8String, RTLD_NOW); + element = objc_lookUpClass("AXElement"); + } + cached = (Class)element; + resolved = YES; } -#else - return @"private XCTest event synthesis unavailable: no resolved application window"; + return cached; +} + +// One contained probe: it checks each selector before sending it and reads the target pid, the +// primary app's frame, and the system app's frame inside one exception scope, so any failed read +// answers Unknown — the same typed nothing-a-guess the decision seam already reports. The target +// pid comes from the existing runner owner for that read (RunnerAXSnapshotBridge), which picks the +// correctly sized send for the pid_t return through the method signature. +static RunnerFullScreenEvidence RunnerProbeApplicationFillsScreen(id application) { + if (application == nil) { + return RunnerFullScreenEvidenceUnknown; + } + Class element = RunnerForegroundElementClass(); + if (element == Nil) { + return RunnerFullScreenEvidenceUnknown; + } + if (![element respondsToSelector:@selector(primaryApp)] || + ![element respondsToSelector:@selector(systemApplication)]) { + return RunnerFullScreenEvidenceUnknown; + } + __block pid_t targetProcessID = 0; + __block pid_t primaryProcessID = 0; + __block CGRect window = CGRectNull; + __block CGRect screen = CGRectNull; + NSString *exception = [RunnerObjCExceptionCatcher catchException:^{ + id primaryApp = [element primaryApp]; + id systemApp = [element systemApplication]; + if (primaryApp == nil || systemApp == nil || + ![primaryApp respondsToSelector:@selector(pid)] || + ![primaryApp respondsToSelector:@selector(frame)] || + ![systemApp respondsToSelector:@selector(frame)]) { + return; + } + targetProcessID = (pid_t)[RunnerAXSnapshotBridge + processIdentifierForApplication:(XCUIApplication *)application]; + primaryProcessID = [primaryApp pid]; + window = [primaryApp frame]; + screen = [systemApp frame]; + }]; + if (exception != nil) { + return RunnerFullScreenEvidenceUnknown; + } + return RunnerFullScreenEvidenceFromForeground(targetProcessID, primaryProcessID, window, screen); +} + #endif + +NSString * _Nullable RunnerResolveSoleScreen( + id application, + id _Nullable *screen, + NSUInteger *displayID +) { + *screen = nil; +#if TARGET_OS_IOS __block NSUInteger screenCount = 0; __block NSUInteger readDisplayID = 0; __block XCUIScreen *soleScreen = nil; @@ -254,9 +345,20 @@ BOOL RunnerResolveApplicationScreen( ); return @"private XCTest event synthesis unavailable: no resolved application window and no sole screen to route to"; } + // 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 route opens only on the fills-screen proof — an iPad can + // show an app in part of its only screen — and windowed or unknown evidence keeps requiring a + // window. The probe answers only what it proved or failed to prove, never "not probed". + if (UIDevice.currentDevice.userInterfaceIdiom != UIUserInterfaceIdiomPhone && + RunnerProbeApplicationFillsScreen(application) != RunnerFullScreenEvidenceFillsScreen) { + return @"private XCTest event synthesis unavailable: no resolved application window, and the sole screen is not provably the app's full-screen frame"; + } *screen = soleScreen; *displayID = readDisplayID; return nil; +#else + return @"private XCTest event synthesis unavailable: no resolved application window"; +#endif } NSString * _Nullable RunnerResolveWindowDisplayID(id window, NSUInteger *displayID) { @@ -265,7 +367,7 @@ BOOL RunnerResolveApplicationScreen( NSValue *frameValue = [window valueForKey:@"frame"]; [frameValue getValue:&frame size:sizeof(frame)]; }]; - if (frameException != nil || !RunnerUsableWindowFrame(frame)) { + if (frameException != nil || !AgentDeviceForegroundFrameIsMeasurable(frame)) { NSLog( @"AGENT_DEVICE_RUNNER_APP_SCREEN_UNRESOLVED stage=window frame=(%g,%g %gx%g) raised=%@", frame.origin.x, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift index 08e8722ed7..b737af1229 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+NavigationTests.swift @@ -50,10 +50,13 @@ extension RunnerTests { } } - /// Only an iPhone with one screen can skip the windows query after the tree rejected one; anywhere - /// else the windows query is still asked, and its window carries the tap. + /// After the tree rejects a query, `back` reaches the top-leading control through whichever route + /// is open: the sole screen where the app proves it fills it (both families here), the resolved + /// window everywhere else. The refusal half on a real device is + /// `testSoleScreenDisplayIDIsNamedOnlyForAnAppThatFillsItsScreen` and the unprovable-foreground + /// test below, which stand where no simulator here can: its foreground app always fills its screen. @MainActor - func testInAppBackStillAsksForWindowsWhereNoSoleScreenAnswers() throws { + func testInAppBackAfterARejectedQueryTapsThroughTheRouteThatOpens() throws { let status = try launchBackFixture(["--agent-device-back-regression"]) defer { endBackFixture() } @@ -67,8 +70,61 @@ extension RunnerTests { XCTAssertEqual(status.label, "Back performed") } + /// The window route the sole-screen rule leaves when the probe cannot prove the fill: with the + /// probe's primary-app read stubbed to answer nothing (the evidence seam's `unknown`), the route + /// closes, and a rejected tree still dispatches `back` through the resolved window. An iPhone + /// keeps the sole-screen route by idiom — it answers before the probe is ever asked, so no stub + /// can close it there and the window route is the off-phone half's own. Real windowed geometry + /// needs an iPad set to Windowed Apps, which no lane runs; `unknown` closes the same route for + /// the same reason — the fill is not proven. + @MainActor + func testInAppBackAfterARejectedQueryWithUnprovableForegroundTapsThroughTheWindow() throws { + let status = try launchBackFixture(["--agent-device-back-regression"]) + defer { endBackFixture() } + // Runs the real probe once, which is also what registers the AXRuntime class this seam stubs. + XCTAssertTrue(soleScreenRouteIsOpen(for: app)) + guard UIDevice.current.userInterfaceIdiom != .phone else { + return + } + let restoreProbe = stubForegroundPrimaryAppAsUnprovable() + defer { restoreProbe() } + XCTAssertFalse( + soleScreenRouteIsOpen(for: app), + "an iPad whose app the probe cannot prove keeps requiring a resolved window" + ) + + let outcome = try withRejectedAppTree(framesReadable: true, rejections: 1) { + tapInAppBackControl(app: app) + } + guard case .performed = outcome else { + return XCTFail("a refused sole-screen route must still dispatch back through the resolved window") + } + XCTAssertEqual(status.label, "Back performed") + } + + /// Stands in for a device showing the app in part of its screen as far as the probe can tell: the + /// probe's `primaryApp` read answers nothing, which the evidence seam reports as `unknown`. The + /// windowed geometry itself needs an iPad set to Windowed Apps, which no lane runs; `unknown` + /// closes the same route for the same reason — the fill is not proven. + private func stubForegroundPrimaryAppAsUnprovable() -> () -> Void { + let element = NSClassFromString("AXElement") + let method = element.flatMap { class_getClassMethod($0, NSSelectorFromString("primaryApp")) } + guard let method else { + preconditionFailure("AXRuntime did not register AXElement before the probe seam was stubbed") + } + let original = method_getImplementation(method) + let replacement = imp_implementationWithBlock( + { (_: AnyObject) -> AnyObject? in nil } as @convention(block) (AnyObject) -> AnyObject? + ) + method_setImplementation(method, replacement) + return { + method_setImplementation(method, original) + imp_removeBlock(replacement) + } + } + private var showsAppsOnlyFullScreen: Bool { - UIDevice.current.userInterfaceIdiom == .phone && XCUIScreen.screens.count == 1 + soleScreenRouteIsOpen(for: app) } /// Launches the runner app; with `--agent-device-back-regression` it shows a top-leading back @@ -180,21 +236,44 @@ extension RunnerTests { } #endif - /// A 402x874 iPhone's navigation bar spans y 62-116 and its back control 62-106. + /// The fallback point sits inside the top bar on both families, from the screen's own top inset: + /// an iPhone 17 Pro measures a 59-pt inset with its bar at y 62-116; an iPad Pro 11" measures 32 + /// with its bar at y 32-86, where a frame-height fraction that serves the iPhone (0.1·874=87, and + /// the old clamp's 96 at any taller height) lands below the iPad's bar. func testTopLeadingNavigationFallbackPointTargetsHeaderControlBand() throws { - let point = try XCTUnwrap( + let iPhone = try XCTUnwrap( Self.topLeadingNavigationFallbackPoint( - in: CGRect(x: 0, y: 0, width: 402, height: 874) + in: CGRect(x: 0, y: 0, width: 402, height: 874), + topSafeAreaInset: 59 ) ) + XCTAssertEqual(iPhone.x, 32.16, accuracy: 0.01) + XCTAssertEqual(iPhone.y, 81, accuracy: 0.01, "inside the iPhone bar band 62-116") - XCTAssertEqual(point.x, 32.16, accuracy: 0.01) - XCTAssertEqual(point.y, 87.4, accuracy: 0.01) + let iPad = try XCTUnwrap( + Self.topLeadingNavigationFallbackPoint( + in: CGRect(x: 0, y: 0, width: 834, height: 1210), + topSafeAreaInset: 32 + ) + ) + XCTAssertEqual(iPad.x, 44, accuracy: 0.01, "the leading-slot cap keeps a tablet tap inside the bar") + XCTAssertEqual(iPad.y, 56, accuracy: 0.01, "inside the iPad bar band 32-86") } func testTopLeadingNavigationFallbackPointRejectsInvalidFrame() { - XCTAssertNil(Self.topLeadingNavigationFallbackPoint(in: .infinite)) - XCTAssertNil(Self.topLeadingNavigationFallbackPoint(in: .zero)) + XCTAssertNil(Self.topLeadingNavigationFallbackPoint(in: .infinite, topSafeAreaInset: 59)) + XCTAssertNil(Self.topLeadingNavigationFallbackPoint(in: .zero, topSafeAreaInset: 59)) + } + + /// A runtime that reports no inset proves nothing about the band, so the point stays inside the + /// compact bar it used to target; a large inset (a surface that pushed the content down) must not + /// send the tap deep into the page. + func testTopLeadingNavigationFallbackPointStaysBoundedForUnknownAndLargeInsets() throws { + let frame = CGRect(x: 0, y: 0, width: 834, height: 1210) + let noInset = try XCTUnwrap(Self.topLeadingNavigationFallbackPoint(in: frame, topSafeAreaInset: 0)) + XCTAssertEqual(noInset.y, 56, accuracy: 0.01, "the compact bar bound") + let hugeInset = try XCTUnwrap(Self.topLeadingNavigationFallbackPoint(in: frame, topSafeAreaInset: 400)) + XCTAssertEqual(hugeInset.y, 96, accuracy: 0.01, "never leaves the header band") } func testNavigationBackControlRankPrefersBackThenCloseThenCancel() { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift index 6d848a006f..f806d300cd 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift @@ -48,13 +48,17 @@ extension RunnerTests { } } + /// Whether the sole-screen route is open for the launched app: an iPhone with one screen always, + /// an iPad only while the app proves it fills that screen. `RunnerResolveSoleScreen` owns the rule; + /// a test reads its answer rather than restating a device list. private var showsAppsOnlyFullScreen: Bool { - UIDevice.current.userInterfaceIdiom == .phone && XCUIScreen.screens.count == 1 + soleScreenRouteIsOpen(for: app) } /// On a tree XCTest cannot serialize, XCTest's coordinate press reads an infinite app frame and - /// raises an exception that ends the runner test. A long press needs no tree: on an iPhone with one - /// screen it is synthesized on that screen, and anything else keeps the XCTest press. + /// raises an exception that ends the runner test. A long press needs no tree: when the sole-screen + /// route is open for the app it is synthesized on that screen, and anything else keeps the XCTest + /// press. @MainActor func testLongPressOnAnUnserializableTreeIsSynthesizedOnTheSoleScreen() throws { app.launch() @@ -101,8 +105,8 @@ extension RunnerTests { } /// A swipe asks for the gesture viewport before it plans its path. On a tree XCTest cannot - /// serialize, an iPhone with one screen answers with that screen, the frame the planned gesture's - /// record is routed to; anything else keeps requiring a window. + /// serialize, the app whose sole screen it provably fills answers with that screen, the frame the + /// planned gesture's record is routed to; anything else keeps requiring a window. @MainActor func testGestureViewportOnAnUnserializableTreeIsTheSoleScreen() throws { app.launch() @@ -153,19 +157,32 @@ extension RunnerTests { } #if os(iOS) - /// The sole-screen route is the boundary every window-less gesture crosses, so it decides the device - /// rule itself: an iPhone with one screen names that screen, and an iPad refuses, because it can show - /// the app in part of its screen. Run on both device families to cover both answers. - func testSoleScreenDisplayIDIsNamedOnlyForAnIPhoneWithOneScreen() { - var displayID: UInt = 0 - let refusal = RunnerResolveSoleScreenDisplayID(&displayID) - if UIDevice.current.userInterfaceIdiom == .phone, XCUIScreen.screens.count == 1 { - XCTAssertNil(refusal) - XCTAssertNotEqual(displayID, 0) - } else { - XCTAssertNotNil(refusal) - XCTAssertEqual(displayID, 0) + /// The launched runner host app fills its simulator's only screen, so its window-less gestures + /// route through that screen on either family. A windowed iPad app is refused the same route — that + /// case needs an iPad set to Windowed Apps, which the nightly lane does not run; what the rule + /// refuses here is the closest executable negative: on an iPad, an app that is not the + /// accessibility primary app has no screen to fill, so naming one would book a tap on another + /// app's frame. An iPhone keeps the rule by idiom, as before this change. + @MainActor + func testSoleScreenDisplayIDIsNamedOnlyForAnAppThatFillsItsScreen() throws { + app.launch() + mainOwned.app = app + defer { + invalidateCachedTarget(reason: "unit_test_cleanup") + app.terminate() + } + XCTAssertTrue( + soleScreenRouteIsOpen(for: app), + "the foreground app fills the simulator's only screen on both families" + ) + guard UIDevice.current.userInterfaceIdiom != .phone else { + return } + + var displayID: UInt = 0 + let refusal = RunnerResolveSoleScreenDisplayID(springboard, &displayID) + XCTAssertNotNil(refusal, "an iPad app that does not own the screen names no sole screen for its taps") + XCTAssertEqual(displayID, 0) } /// #3899: a synthesized turn swaps the display's side lengths, never a placed window's smaller box. diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+XCTestEventBridgeTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+XCTestEventBridgeTests.swift index a9c7f1accf..289012ccf7 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+XCTestEventBridgeTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+XCTestEventBridgeTests.swift @@ -203,5 +203,49 @@ 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 windowed/unknown rows below fail without + /// the fills-screen proof, on any family and on the macOS host lane, and each keeps its exact + /// typed outcome — `not fills-screen` alone cannot tell windowed from unknown. + func testFullScreenEvidenceDecidesFromForegroundValues() { + let screen = CGRect(x: 0, y: 0, width: 834, height: 1210) + let cases: [(name: String, targetPid: pid_t, primaryPid: pid_t, window: CGRect, screen: CGRect, expected: RunnerFullScreenEvidence)] = [ + ("same frame", 100, 100, screen, screen, .fillsScreen), + // Sub-pixel layout rounding is not a window; it stays inside the 0.5 tolerance. + ("0.4-pt displacement", 100, 100, screen.offsetBy(dx: 0.4, dy: -0.4), screen, .fillsScreen), + // A window that shows the app in part of the screen (Windowed Apps or Stage Manager). + ("placed window", 100, 100, CGRect(x: 224, y: 128, width: 386, height: 954), screen, .windowed), + // Each component alone, so no comparison can be dropped for an equal-size shortcut. + ("1-pt minX past the tolerance", 100, 100, screen.offsetBy(dx: 1, dy: 0), screen, .windowed), + ("1-pt minY past the tolerance", 100, 100, screen.offsetBy(dx: 0, dy: 1), screen, .windowed), + ("1-pt narrower", 100, 100, screen.insetBy(dx: 0.5, dy: 0), screen, .windowed), + ("1-pt shorter", 100, 100, screen.insetBy(dx: 0, dy: 0.5), screen, .windowed), + // A window at the origin is still potentially windowed: the origin proves nothing alone. + ("smaller window at (0,0)", 100, 100, CGRect(x: 0, y: 0, width: 834, height: 600), screen, .windowed), + // A different app owns the foreground, so the screen is not this app's to fill. + ("foreign primary pid", 100, 77, screen, screen, .notPrimaryApp), + // An unreadable pid or frame proves nothing, and the answer is Unknown, never a guess. + ("unreadable target pid", 0, 100, screen, screen, .unknown), + ("unreadable primary pid", 100, 0, screen, screen, .unknown), + ("null window", 100, 100, .null, screen, .unknown), + ("empty window", 100, 100, .zero, screen, .unknown), + ("null screen", 100, 100, screen, .null, .unknown), + ("empty screen", 100, 100, screen, .zero, .unknown), + // The infinite sentinel is the framework's own "no measurement" marker (CGRectInfinite's + // components are finite extremes), so the predicate refuses it and the answer is Unknown. + ("infinite window", 100, 100, .infinite, screen, .unknown), + // A non-finite frame component is a runtime that said nothing, not a window that differs: + // the answer is Unknown, never the Windowed a comparison of the numbers would read as. + ("NaN window origin", 100, 100, CGRect(x: CGFloat.nan, y: 0, width: 834, height: 1210), screen, .unknown), + ("infinite screen width", 100, 100, screen, CGRect(x: 0, y: 0, width: CGFloat.infinity, height: 1210), .unknown), + ] + for (name, targetPid, primaryPid, window, screenFrame, expected) in cases { + XCTAssertEqual( + RunnerFullScreenEvidenceFromForeground(targetPid, primaryPid, window, screenFrame), + expected, + name + ) + } + } } #endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/SoleScreenRouteReading.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/SoleScreenRouteReading.swift new file mode 100644 index 0000000000..2516545646 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/SoleScreenRouteReading.swift @@ -0,0 +1,17 @@ +import XCTest + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS +/// Shared reading of the sole-screen rule for the runner unit bundle. +/// +/// The rule has one owner (`RunnerResolveSoleScreen`), so a test that asks "does this device family +/// show the app full on its only screen" must read its answer rather than restate a device list. +extension RunnerTests { + /// Whether the launched app can route a window-less gesture through the device's only screen: an + /// iPhone always, an iPad only while the app proves it fills that screen. + func soleScreenRouteIsOpen(for app: XCUIApplication) -> Bool { + var screen: AnyObject? + var displayID: UInt = 0 + return RunnerResolveSoleScreen(app, &screen, &displayID) == nil + } +} +#endif diff --git a/apple/snapshot-bridge/ForegroundApplicationGeometry.h b/apple/snapshot-bridge/ForegroundApplicationGeometry.h new file mode 100644 index 0000000000..c45954ce64 --- /dev/null +++ b/apple/snapshot-bridge/ForegroundApplicationGeometry.h @@ -0,0 +1,56 @@ +// ForegroundApplicationGeometry.h — the one full-screen rule two native targets ask. +// +// The Simulator snapshot bridge refuses a capture whose app window does not fill the screen +// (`app-window-not-full-screen`, #3844), and the iOS runner refuses to route a window-less +// gesture onto a screen it cannot prove the app fills. Both include this header, so the rule is +// one compilation, not a pinned copy. Each caller keeps its own unknown policy: the bridge keeps +// a capture it cannot measure, the runner refuses authorization it cannot prove. +// +// Both consumers include this file by bare name: the snapshot bridge compiles it from its own +// directory, the runner's UITests target finds it through HEADER_SEARCH_PATHS. + +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol AgentDeviceForegroundElement ++ (nullable id)primaryApp; ++ (nullable id)systemApplication; +- (pid_t)pid; +- (CGRect)frame; +@end + +// While a system surface owns the foreground, no screen is the target app's to fill and no tree is +// the target app's to capture (ADR 0004, foreground ownership). +static inline BOOL AgentDeviceForegroundOwnershipMatches(pid_t primaryProcessID, pid_t targetProcessID) { + return primaryProcessID == targetProcessID; +} + +// Sub-pixel layout rounding is not a window; anything past this is one that shows the app in part +// of the screen (Windowed Apps or Stage Manager). +#define AGENT_DEVICE_FULL_SCREEN_FRAME_TOLERANCE 0.5 + +// A null, empty, infinite-sentinel, or non-finite-component frame is not a measurement at all: it +// proves neither the fill nor the window, so it must reach each caller's unknown policy, never a +// comparison that reads as a difference. This is the whole usability test; callers make one call. +static inline BOOL AgentDeviceForegroundFrameIsMeasurable(CGRect frame) { + if (CGRectIsNull(frame) || CGRectIsEmpty(frame) || CGRectIsInfinite(frame)) return NO; + return isfinite(frame.origin.x) && isfinite(frame.origin.y) && + isfinite(frame.size.width) && isfinite(frame.size.height); +} + +static inline BOOL AgentDeviceForegroundFramesAreMeasurable(CGRect window, CGRect screen) { + return AgentDeviceForegroundFrameIsMeasurable(window) && + AgentDeviceForegroundFrameIsMeasurable(screen); +} + +static inline BOOL AgentDeviceForegroundWindowFillsScreen(CGRect window, CGRect screen) { + return fabs(window.origin.x - screen.origin.x) <= AGENT_DEVICE_FULL_SCREEN_FRAME_TOLERANCE && + fabs(window.origin.y - screen.origin.y) <= AGENT_DEVICE_FULL_SCREEN_FRAME_TOLERANCE && + fabs(window.size.width - screen.size.width) <= AGENT_DEVICE_FULL_SCREEN_FRAME_TOLERANCE && + fabs(window.size.height - screen.size.height) <= AGENT_DEVICE_FULL_SCREEN_FRAME_TOLERANCE; +} + +NS_ASSUME_NONNULL_END diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.m b/apple/snapshot-bridge/SnapshotBridgeRuntime.m index c59949077b..8dc5b2a6a6 100644 --- a/apple/snapshot-bridge/SnapshotBridgeRuntime.m +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.m @@ -67,12 +67,7 @@ @protocol XCAccessibilityElementFactory + (nullable XCAccessibilityElement *)elementWithProcessIdentifier:(pid_t)pid; @end -@protocol AXForegroundElement -+ (nullable id)primaryApp; -+ (nullable id)systemApplication; -- (pid_t)pid; -- (CGRect)frame; -@end +#import "ForegroundApplicationGeometry.h" static NSNumber *finiteNumber(double value) { @@ -183,10 +178,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 +191,22 @@ - (BOOL)isPrimaryForegroundProcess:(pid_t)pid // is refused; a runtime that cannot report either frame keeps the capture. - (BOOL)primaryAppWindowDiffersFromScreen { - Class elementClass = (Class)objc_lookUpClass("AXElement"); + Class elementClass = (Class)objc_lookUpClass("AXElement"); if (![elementClass respondsToSelector:@selector(primaryApp)] || ![elementClass respondsToSelector:@selector(systemApplication)]) { return NO; } - id application = [elementClass primaryApp]; - id system = [elementClass systemApplication]; + id application = [elementClass primaryApp]; + id system = [elementClass systemApplication]; if (![application respondsToSelector:@selector(frame)] || ![system respondsToSelector:@selector(frame)]) { return NO; } CGRect window = [application frame]; CGRect screen = [system frame]; - if (CGRectIsEmpty(window) || CGRectIsEmpty(screen)) return NO; - return fabs(CGRectGetMinX(window) - CGRectGetMinX(screen)) > 0.5 || - fabs(CGRectGetMinY(window) - CGRectGetMinY(screen)) > 0.5 || - fabs(CGRectGetWidth(window) - CGRectGetWidth(screen)) > 0.5 || - fabs(CGRectGetHeight(window) - CGRectGetHeight(screen)) > 0.5; + // The documented unknown-frame fallback: a frame that is not a usable measurement is a runtime + // that reported nothing, not a window that differs from the screen, so the capture is kept. + if (!AgentDeviceForegroundFramesAreMeasurable(window, screen)) return NO; + return !AgentDeviceForegroundWindowFillsScreen(window, screen); } - (nullable id)jsonValue:(id)value name:(NSString *)name diff --git a/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index cc65bfac0d..95dca7102d 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -753,6 +753,8 @@ Both frames use the native space of the screen, also in landscape. If the frames differ, the guest refuses the capture (`app-window-not-full-screen`) before acquisition. This refusal provides evidence about the screen arrangement, not the app, so it has capture scope. If the runtime cannot report either frame, the guest keeps the capture. +The runner's synthesized-gesture route compares the same two frames for the same question (`RunnerFullScreenEvidenceFromForeground`). +An iPad app that proves it fills its sole screen may use that screen as its gesture reference frame when XCTest cannot resolve its window; one that does not, keeps requiring a window. The bridge still refuses and converts nothing; the XCTest fallback publishes a windowed app in screen space (#3899 amendment above). diff --git a/docs/adr/0025-foldable-apple-panels.md b/docs/adr/0025-foldable-apple-panels.md index 6543c705ef..b5e4645d7c 100644 --- a/docs/adr/0025-foldable-apple-panels.md +++ b/docs/adr/0025-foldable-apple-panels.md @@ -153,9 +153,9 @@ The `back` fallback compares before/after captures to determine whether this app Two SpringBoard home-screen captures are byte-identical regardless of the app's behavior. System-surface fallback would therefore report false "unchanged" evidence for the failure under test. The check observes the display of the app's resolved window. -Exception: if XCTest cannot resolve a window on an iPhone with exactly one screen, the check uses the same sole screen as synthesized dispatch. -An iPhone app occupies that screen, so this exception needs no app-tree query. -iPad and multi-screen devices still require a resolved window. +Exception: if XCTest cannot resolve a window and the sole screen is provably the app's full-screen frame, the check uses that screen, the same route synthesized dispatch takes. +An iPhone app always occupies its sole screen; an iPad app does so only while the runner's full-screen proof (`RunnerFullScreenEvidenceFromForeground`) proves it. +A multi-screen device and a windowed iPad app still require a resolved window. If neither route can name the display, typed refusal produces an unknown outcome instead of false no-change evidence from another process (#2728). Required and optional helper consumers differ in their frame requirements. diff --git a/packages/platform-apple/src/runner/__tests__/runner-source.test.ts b/packages/platform-apple/src/runner/__tests__/runner-source.test.ts index 8d93d4f0e0..586ede2359 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-source.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-source.test.ts @@ -54,6 +54,41 @@ test('resolveAppleSnapshotPresentationSourceRoot falls back to packaged source', assert.equal(resolveAppleSnapshotPresentationSourceRoot(root), packagedSource); }); +test('computeRunnerSourceFingerprint covers the shared foreground geometry header in the checkout layout', () => { + const root = makeTempRoot(); + fs.mkdirSync(path.join(root, 'apple', 'runner', 'AgentDeviceRunner'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'apple', 'runner', 'AgentDeviceRunner', 'Runner.swift'), + 'runner\n', + ); + const header = path.join(root, 'apple', 'snapshot-bridge', 'ForegroundApplicationGeometry.h'); + fs.mkdirSync(path.dirname(header), { recursive: true }); + fs.writeFileSync(header, '#pragma once\n'); + + const before = computeRunnerSourceFingerprint(root); + fs.writeFileSync(header, '#pragma once\n// changed\n'); + + assert.notEqual(computeRunnerSourceFingerprint(root), before); +}); + +test('computeRunnerSourceFingerprint covers the shared header when the runner comes from the packaged layout', () => { + const root = makeTempRoot(); + fs.mkdirSync( + path.join(root, 'dist', 'apple', 'runner', 'AgentDeviceRunner', 'AgentDeviceRunner.xcodeproj'), + { recursive: true }, + ); + // An installed package keeps the header at the same root-relative path the checkout uses; only + // the runner source moves under dist/. + const header = path.join(root, 'apple', 'snapshot-bridge', 'ForegroundApplicationGeometry.h'); + fs.mkdirSync(path.dirname(header), { recursive: true }); + fs.writeFileSync(header, '#pragma once\n'); + + const before = computeRunnerSourceFingerprint(root); + fs.writeFileSync(header, '#pragma once\n// changed\n'); + + assert.notEqual(computeRunnerSourceFingerprint(root), before); +}); + test('computeRunnerSourceFingerprint covers the shared snapshot presentation sources', () => { const root = makeTempRoot(); fs.mkdirSync(path.join(root, 'apple', 'runner', 'AgentDeviceRunner'), { recursive: true }); diff --git a/packages/platform-apple/src/runner/apple-runner-platform.ts b/packages/platform-apple/src/runner/apple-runner-platform.ts index a25ee90ef2..42d8c4c6ab 100644 --- a/packages/platform-apple/src/runner/apple-runner-platform.ts +++ b/packages/platform-apple/src/runner/apple-runner-platform.ts @@ -22,6 +22,15 @@ import { readHostCpuArchSync } from './host.ts'; */ export const COLD_TOOLCHAIN_PROBE_TIMEOUT_MS = 30_000; +/** + * The snapshot bridge's shared full-screen rule, named once. The bridge's runtime includes it and + * the runner's UITests target compiles against it through HEADER_SEARCH_PATHS, so its bytes change + * both compiled binaries. Beside the probe ceiling above so the constant sits with the constants + * this package shares: the bridge cache identity and the runner fingerprint both read it without + * either subtree entering the other's graph. + */ +export const SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME = 'ForegroundApplicationGeometry.h'; + /** * The symlink `xcode-select -s` writes and `xcode-select -p` reads back. Resolving it answers which * developer dir the host selects, without a subprocess, so the toolchain memo can key on that answer diff --git a/packages/platform-apple/src/runner/index.ts b/packages/platform-apple/src/runner/index.ts index 8639f05c84..af16aa0766 100644 --- a/packages/platform-apple/src/runner/index.ts +++ b/packages/platform-apple/src/runner/index.ts @@ -20,3 +20,4 @@ export { } from './runner-provider.ts'; export { buildRunnerSequenceCommand, parseRunnerSequenceResult } from './runner-sequence.ts'; export { resolveRunnerContainerBundleIds } from './runner-cache-metadata.ts'; +export { SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME } from './apple-runner-platform.ts'; diff --git a/packages/platform-apple/src/runner/runner-source.ts b/packages/platform-apple/src/runner/runner-source.ts index 58a2678cca..f1836c4434 100644 --- a/packages/platform-apple/src/runner/runner-source.ts +++ b/packages/platform-apple/src/runner/runner-source.ts @@ -1,6 +1,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; +import { SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME } from './apple-runner-platform.ts'; const APPLE_RUNNER_SOURCE_ROOT = path.join('apple', 'runner', 'AgentDeviceRunner'); const PACKAGED_APPLE_RUNNER_SOURCE_ROOT = path.join('dist', 'apple', 'runner', 'AgentDeviceRunner'); @@ -10,6 +11,15 @@ const PACKAGED_APPLE_SNAPSHOT_PRESENTATION_SOURCE_ROOT = path.join( 'apple', 'snapshot-presentation', ); +// The UITests target compiles RunnerXCTestEventBridge.m against this header through +// HEADER_SEARCH_PATHS. It is one exact file whose bytes change the compiled runner, and the +// package ships it at the same root-relative path the checkout keeps it at, so the fingerprint +// reads it from `projectRoot` in whichever layout produced it. +const RUNNER_SHARED_HEADER_PATH = path.join( + 'apple', + 'snapshot-bridge', + SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME, +); export function resolveAppleRunnerSourceRoot(projectRoot: string): string { const checkoutSourceRoot = path.join(projectRoot, APPLE_RUNNER_SOURCE_ROOT); @@ -66,6 +76,11 @@ export function computeRunnerSourceFingerprint(projectRoot: string): string { }, ]; const files = collectRunnerSourceFiles(sourceRoots); + const sharedHeader = path.join(projectRoot, RUNNER_SHARED_HEADER_PATH); + if (fs.existsSync(sharedHeader)) { + files.push(sharedHeader); + } + files.sort((a, b) => a.localeCompare(b)); const fileStatsFingerprint = computeRunnerSourceFileStatsFingerprint(projectRoot, files); const cacheKey = JSON.stringify(sourceRoots.map(({ path: sourcePath }) => sourcePath)); const cached = runnerSourceFingerprintCache.get(cacheKey); diff --git a/packages/platform-apple/src/snapshot-source/adapter.test.ts b/packages/platform-apple/src/snapshot-source/adapter.test.ts index 3c9df88ae0..ed243c44e1 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.test.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.test.ts @@ -36,6 +36,7 @@ test('the Simulator AX source returns raw acquisition facts and discloses unsupp await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header'); + await writeFile(path.join(sourceRoot, 'ForegroundApplicationGeometry.h'), 'shared rule'); const fixture = createAdapterHost(); const source = createSimulatorSnapshotSource({ host: fixture.host, @@ -138,6 +139,7 @@ test('the Simulator AX source refuses a tree that ends at content another proces 'SnapshotBridgeRuntime.h', 'SnapshotBridgeCapture.h', 'SnapshotBridgeCapture.m', + 'ForegroundApplicationGeometry.h', ]) { await writeFile(path.join(sourceRoot, name), 'native source'); } @@ -188,6 +190,7 @@ test('a capture reports a cold preparation instead of spending its deadline on i await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header'); + await writeFile(path.join(sourceRoot, 'ForegroundApplicationGeometry.h'), 'shared rule'); const fixture = createAdapterHost(300); const source = createSimulatorSnapshotSource({ host: fixture.host, sourceRoot, cacheRoot }); const hint = deriveIosCaptureHint(createIosSnapshotRequest()); @@ -232,6 +235,7 @@ test('the Simulator AX source learns a hint only from a validated acquisition', 'SnapshotBridgeRuntime.h', 'SnapshotBridgeCapture.h', 'SnapshotBridgeCapture.m', + 'ForegroundApplicationGeometry.h', ]) { await writeFile(path.join(sourceRoot, name), 'native source'); } @@ -433,6 +437,7 @@ test('the Simulator AX source follows the shared hint contract', async () => { 'SnapshotBridgeRuntime.h', 'SnapshotBridgeCapture.h', 'SnapshotBridgeCapture.m', + 'ForegroundApplicationGeometry.h', ]) { await writeFile(path.join(sourceRoot, name), 'native source'); } @@ -726,6 +731,7 @@ test('the Simulator AX source refuses a window whose coordinate space it cannot 'SnapshotBridgeRuntime.h', 'SnapshotBridgeCapture.h', 'SnapshotBridgeCapture.m', + 'ForegroundApplicationGeometry.h', ]) { await writeFile(path.join(sourceRoot, name), 'native source'); } diff --git a/packages/platform-apple/src/snapshot-source/cache-identity.ts b/packages/platform-apple/src/snapshot-source/cache-identity.ts index 6b3260553f..83b1583fd6 100644 --- a/packages/platform-apple/src/snapshot-source/cache-identity.ts +++ b/packages/platform-apple/src/snapshot-source/cache-identity.ts @@ -2,6 +2,7 @@ import { readHostToolchainIdentity, type HostToolchainIdentity, } from '../native-build/toolchain-identity.ts'; +import { SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME } from '../runner/apple-runner-platform.ts'; import { fromNativeBuildError, snapshotSourceError } from './errors.ts'; import type { SnapshotSourceDeadline } from './deadline.ts'; import type { SnapshotSourceHost } from './types.ts'; @@ -17,6 +18,9 @@ export const SNAPSHOT_BRIDGE_SOURCE_FILENAMES = [ 'SnapshotBridgeRuntime.h', 'SnapshotBridgeCapture.h', 'SnapshotBridgeCapture.m', + // Not a compile unit: the clang argv lists translation units only, and an included header + // belongs in the fingerprint, not argv. + SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME, ] as const; export const SNAPSHOT_BRIDGE_COMPILE_FILENAMES = [ 'SnapshotBridge.m', diff --git a/packages/platform-apple/src/snapshot-source/cache.test.ts b/packages/platform-apple/src/snapshot-source/cache.test.ts index 4fcacccead..7f5967c671 100644 --- a/packages/platform-apple/src/snapshot-source/cache.test.ts +++ b/packages/platform-apple/src/snapshot-source/cache.test.ts @@ -26,6 +26,7 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header v1'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header v1'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header v1'); + await writeFile(path.join(sourceRoot, 'ForegroundApplicationGeometry.h'), 'shared rule v1'); let builds = 0; let xcodeVersion = 'Xcode 16.4\nBuild version 16F6'; @@ -110,6 +111,21 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt assert.notEqual(sourceChanged.sourceHash, first.sourceHash); assert.equal(builds, 3); + // The shared full-screen header is compiled into the bridge (SnapshotBridgeRuntime.m includes + // it), so changing only the header must bust the cache — a build kept across a rule edit would + // ship the old refusal from bytes the fingerprint never read. + await writeFile(path.join(sourceRoot, 'ForegroundApplicationGeometry.h'), 'shared rule v2'); + const headerChanged = await ensureSnapshotBridgeBinary({ + host, + runtime: 'iOS 26.2', + limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS, + deadline: testDeadline(), + sourceRoot, + cacheRoot, + }); + assert.notEqual(headerChanged.sourceHash, sourceChanged.sourceHash); + assert.equal(builds, 4); + xcodeVersion = 'Xcode 16.5\nBuild version 16F5'; const toolchainChanged = await ensureSnapshotBridgeBinary({ host, @@ -120,7 +136,7 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt cacheRoot, }); assert.notEqual(toolchainChanged.cacheKey, sourceChanged.cacheKey); - assert.equal(builds, 4); + assert.equal(builds, 5); } finally { await rm(root, { recursive: true, force: true }); } @@ -145,6 +161,7 @@ test('concurrent snapshot bridge preparation publishes one cache entry', async ( await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header'); + await writeFile(path.join(sourceRoot, 'ForegroundApplicationGeometry.h'), 'shared rule'); let builds = 0; const host = createFakeBuildHost(async () => { builds += 1; @@ -183,6 +200,7 @@ test('an aborted cache waiter does not cancel an independent preparation', async await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header'); + await writeFile(path.join(sourceRoot, 'ForegroundApplicationGeometry.h'), 'shared rule'); let builds = 0; let buildStarted!: () => void; const started = new Promise((resolve) => { @@ -235,6 +253,7 @@ test('a bridge build that failed is reported over a cache lock that could not be await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header'); + await writeFile(path.join(sourceRoot, 'ForegroundApplicationGeometry.h'), 'shared rule'); const buildHost = createFakeBuildHost('unused'); const host: SnapshotSourceHost = { ...buildHost, @@ -287,6 +306,7 @@ test('a compile exec killed at its budget reports the bridge build, not the exec await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header'); + await writeFile(path.join(sourceRoot, 'ForegroundApplicationGeometry.h'), 'shared rule'); const buildHost = createFakeBuildHost('binary'); let compileTimeoutMs = 0; const host: SnapshotSourceHost = { diff --git a/packages/platform-apple/src/snapshot-source/preparation.test.ts b/packages/platform-apple/src/snapshot-source/preparation.test.ts index 33d66c0a7b..816d036cc0 100644 --- a/packages/platform-apple/src/snapshot-source/preparation.test.ts +++ b/packages/platform-apple/src/snapshot-source/preparation.test.ts @@ -11,17 +11,10 @@ import { type SnapshotBridgePreparation, SNAPSHOT_BRIDGE_PREPARATION_RETRY_AFTER_MS, } from './preparation.ts'; +import { SNAPSHOT_BRIDGE_SOURCE_FILENAMES } from './cache-identity.ts'; import type { SnapshotSourceBridgeBinary, SnapshotSourceHost } from './types.ts'; import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; -const BRIDGE_SOURCES = [ - 'SnapshotBridge.m', - 'SnapshotBridgeRuntime.m', - 'SnapshotBridgeRuntime.h', - 'SnapshotBridgeCapture.h', - 'SnapshotBridgeCapture.m', -]; - test('one detached preparation answers every capture that arrives while it runs', async () => { const root = await writeBridgeSource('preparation-shared-'); let releaseBuild = () => {}; @@ -197,7 +190,7 @@ async function writeBridgeSource(prefix: string): Promise { const root = await mkdtempForTest(`agent-device-bridge-${prefix}`); const sourceRoot = path.join(root, 'source'); await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot); - for (const name of BRIDGE_SOURCES) { + for (const name of SNAPSHOT_BRIDGE_SOURCE_FILENAMES) { await writeFile(path.join(sourceRoot, name), 'native source'); } return root; diff --git a/scripts/__tests__/fixtures/size-report-npm-pack.json b/scripts/__tests__/fixtures/size-report-npm-pack.json index 1a11bb6334..8494a3fcc3 100644 --- a/scripts/__tests__/fixtures/size-report-npm-pack.json +++ b/scripts/__tests__/fixtures/size-report-npm-pack.json @@ -37,6 +37,10 @@ "path": "apple/snapshot-bridge/SnapshotBridgeCapture.m", "size": 0 }, + { + "path": "apple/snapshot-bridge/ForegroundApplicationGeometry.h", + "size": 0 + }, { "path": "apple/macos-helper/Package.swift", "size": 1057 @@ -78,6 +82,6 @@ "size": 426 } ], - "entryCount": 19, + "entryCount": 20, "size": 3376 } diff --git a/scripts/__tests__/xctest-selection.test.ts b/scripts/__tests__/xctest-selection.test.ts index 4f066032fe..d61d135abd 100644 --- a/scripts/__tests__/xctest-selection.test.ts +++ b/scripts/__tests__/xctest-selection.test.ts @@ -86,6 +86,11 @@ describe('the real tree', () => { expect(reachedAnywhere.size).toBe(declared - 1); expect(reachedAnywhere.has(ENTRY_POINT)).toBe(false); for (const id of report.reach.pr) expect(report.reach.nightly.has(id)).toBe(true); + // The iPad leg is a named selection inside the nightly's whole-bundle reach: every test it + // names must exist, compile for iOS, and stay small enough to be a selection, not a suite. + expect(report.reach['nightly-ipad'].size).toBeGreaterThan(0); + expect(report.reach['nightly-ipad'].size).toBeLessThanOrEqual(5); + for (const id of report.reach['nightly-ipad']) expect(report.reach.nightly.has(id)).toBe(true); expect([...report.reach.pr].sort()).toEqual([...report.sharedPlatformBranchIds].sort()); expect(iosPrTestIdentifiers(report.declaredTests, report.sharedPlatformBranchIds)).toEqual([ ...report.reach.pr, @@ -170,6 +175,12 @@ describe('the real tree', () => { const packageManifestPath = path.join(root, 'apple/snapshot-presentation/Package.runner.swift'); fs.mkdirSync(path.dirname(packageManifestPath), { recursive: true }); fs.writeFileSync(packageManifestPath, '// fixture package manifest\n'); + const sharedHeaderPath = path.join( + root, + 'apple/snapshot-bridge/ForegroundApplicationGeometry.h', + ); + fs.mkdirSync(path.dirname(sharedHeaderPath), { recursive: true }); + fs.writeFileSync(sharedHeaderPath, '#pragma once\n'); const sourcePath = path.join( root, 'apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Fixture.swift', @@ -488,12 +499,12 @@ describe('the blind-parse guards', () => { describe('the summary line', () => { test('reports the per-lane reach a reader needs to see the partition', () => { const report = loadReport(repoRoot); - const { declared, host, pr, nightly, dark } = counts(report); + const { declared, host, pr, nightly, nightlyIpad, dark } = counts(report); const summary = formatSummary(report); expect(summary).toContain(`${declared} declared`); expect(summary).toContain(`reaches ${host}, PR iOS branch lane`); expect(summary).toContain(`selects ${pr}, nightly`); - expect(summary).toContain(`reaches ${nightly};`); + expect(summary).toContain(`reaches ${nightly}, its iPad leg selects ${nightlyIpad};`); expect(summary).toContain(`${dark} reachable by no lane`); }); }); diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index db529ea4eb..267635e4f8 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -136,6 +136,17 @@ test('the scripts that rewrite runner Swift select the packaged-source check', ( assert.ok(ids(['apple/runner/Sources/Runner/Main.swift']).includes('packaged-runner-swift')); }); +test('the shared foreground geometry header selects the Apple runner builds', () => { + // The runner's event bridge includes this bridge header by relative path, so its bytes change + // the compiled runner on both platform lanes; a change nothing builds cannot be reviewed safely. + for (const check of ['swift-runner-ios', 'swift-runner-macos', 'packaged-runner-swift']) { + assert.ok( + ids(['apple/snapshot-bridge/ForegroundApplicationGeometry.h']).includes(check), + check, + ); + } +}); + test('Android helper change selects the android-helpers build', () => { assert.deepEqual(ids(['android/snapshot-helper/src/Main.kt']), [ 'android-helpers', diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index 6cdd59ed8b..de27de35d3 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -387,6 +387,7 @@ const daemonWireCompatOwnership: OwnershipRule = ({ file }) => { const ownsAppleRunnerBuildSource = (file: string): boolean => file.startsWith('apple/runner/') || file.startsWith('apple/snapshot-presentation/') || + file === 'apple/snapshot-bridge/ForegroundApplicationGeometry.h' || (file.startsWith('packages/platform-apple/src/runner/') && !file.includes('/__tests__/')) || file.endsWith('.swift'); @@ -440,6 +441,7 @@ const BUILD_OWNERSHIP: ReadonlyArray<{ detail: 'packaged runner Swift must still parse and keep the checkout line numbering', owns: (file) => file.startsWith('apple/runner/') || + file === 'apple/snapshot-bridge/ForegroundApplicationGeometry.h' || file === 'scripts/package-apple-runner-source.mjs' || file === 'scripts/strip-swift-comments.mjs', }, diff --git a/scripts/check-packaged-runner-swift.ts b/scripts/check-packaged-runner-swift.ts index 8a06d58122..c0a25aeff9 100644 --- a/scripts/check-packaged-runner-swift.ts +++ b/scripts/check-packaged-runner-swift.ts @@ -28,6 +28,7 @@ import os from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { runCmdSync } from '@agent-device/host-kit/command'; +import { SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME } from '@agent-device/platform-apple/runner'; const repoRoot = path.resolve(import.meta.dirname, '..'); const packageAppleRunnerScript = path.join(repoRoot, 'scripts/package-apple-runner-source.mjs'); @@ -145,6 +146,48 @@ function swiftParseFailures( }); } +/** + * The packaged UITests target finds the shared full-screen header through HEADER_SEARCH_PATHS. + * Resolve the packaged project's own search paths in the scratch packaging layout: the package + * ships `apple/snapshot-bridge/` at its root, so at least one entry must land on that directory + * and carry the header RunnerXCTestEventBridge.m includes by bare name. + * + * No other job would miss a broken search path: every xcodebuild lane (smoke-ios-run, smoke-macos, + * xctest-nightly) compiles the checkout tree, and check-package's clean install never builds the + * packaged runner — the first compile of that tree is a user's. This is that user's build test. + */ +function sharedHeaderFailures(scratchRoot: string, packagedRoot: string): string[] { + const project = fs.readFileSync( + path.join(packagedRoot, 'AgentDeviceRunner', 'AgentDeviceRunner.xcodeproj', 'project.pbxproj'), + 'utf8', + ); + const searchPath = /HEADER_SEARCH_PATHS = \(([\s\S]*?)\n\s*\);/.exec(project)?.[1]; + if (searchPath === undefined) { + return [`${PACKAGED_DIR}: the packaged project declares no HEADER_SEARCH_PATHS.`]; + } + const srcRoot = path.join(packagedRoot, 'AgentDeviceRunner'); + const resolvesToHeader = searchPath + .split(',') + .map((entry) => entry.trim().replaceAll('"', '')) + .filter((entry) => entry.startsWith('$(SRCROOT)/')) + .some((entry) => + fs.existsSync( + path.join( + srcRoot, + entry.replace('$(SRCROOT)', '.'), + SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME, + ), + ), + ); + if (!resolvesToHeader) { + return [ + `${PACKAGED_DIR}: no HEADER_SEARCH_PATHS entry resolves to ${SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME} ` + + `from ${path.join(PACKAGED_DIR, 'AgentDeviceRunner')} — a user's xcodebuild cannot compile the runner.`, + ]; + } + return []; +} + function main(): number { const scratchRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-packaged-swift-')); try { @@ -152,6 +195,7 @@ function main(): number { const relativePaths = swiftFilesUnder(packagedRoot); const swiftc = findSwiftc(); const failures = [ + ...sharedHeaderFailures(scratchRoot, packagedRoot), ...lineParityFailures(packagedRoot, relativePaths), ...(swiftc === undefined ? [] diff --git a/scripts/check-xctest-selection.ts b/scripts/check-xctest-selection.ts index 89ad42762b..024d76d3e9 100644 --- a/scripts/check-xctest-selection.ts +++ b/scripts/check-xctest-selection.ts @@ -3,7 +3,8 @@ // (#1781 A7). Also ensures those unit-test methods are stripped from the Apple runner source // copied into the npm package. // -// Three lanes run the `AgentDeviceRunnerUITests` bundle, and each reaches a different set: +// Three workflows run the `AgentDeviceRunnerUITests` bundle over four lanes, and each reaches a +// different set: // // - host smoke-macos/action.yml, macOS host, every PR: the whole bundle as compiled for macOS, minus // `-skip-testing:` — the pure runner-decision tests, whose guard is @@ -13,6 +14,8 @@ // iOS, minus `-skip-testing:` — includes the simulator-only tests, whose guard is // `… && os(iOS)` (they launch the host app, route through SpringBoard, or assert an // iOS-only branch). +// - nightly-ipad xctest-nightly.yml's iPad leg: the named `-only-testing:` selection only — +// the sole-screen route's off-phone half, which runs nowhere else. // // The classification therefore lives in the `#if` guards, so this check evaluates them per // platform rather than treating a source-level `func test…` as running everywhere. What it @@ -52,14 +55,17 @@ export const PR_XCTEST_SOURCE_FILE = '.github/actions/smoke-ios-run/action.yml'; /** The nightly lane, whose `-skip-testing:` list decides what the full simulator suite leaves out. */ export const NIGHTLY_WORKFLOW_FILE = '.github/workflows/xctest-nightly.yml'; -export type LaneId = 'host' | 'pr' | 'nightly'; +export type LaneId = 'host' | 'pr' | 'nightly' | 'nightly-ipad'; export type Lane = { readonly id: LaneId; readonly workflow: string; readonly platform: Platform; - /** `whole`: everything compiled minus `-skip-testing:`; `platform-branch`: derived from guards. */ - readonly selection: 'whole' | 'platform-branch'; + /** + * `whole`: everything compiled minus `-skip-testing:`; `platform-branch`: derived from guards; + * `named-only`: exactly the lane's own `-only-testing:` selection. + */ + readonly selection: 'whole' | 'platform-branch' | 'named-only'; /** The job-summary heading the lane's reporter prints. */ readonly title: string; }; @@ -86,6 +92,13 @@ export const LANES: readonly Lane[] = [ selection: 'whole', title: 'iOS runner full XCTest suite', }, + { + id: 'nightly-ipad', + workflow: NIGHTLY_WORKFLOW_FILE, + platform: 'iOS', + selection: 'named-only', + title: 'iPad runner sole-screen selection', + }, ]; export function lane(id: string): Lane { @@ -102,7 +115,9 @@ export function lane(id: string): Lane { * Every workflow whose test identifiers this check owns. All are read, so a workflow that is * renamed or deleted fails here rather than leaving a stale claim in the output. */ -export const GUARDED_WORKFLOWS: readonly string[] = LANES.map((entry) => entry.workflow); +export const GUARDED_WORKFLOWS: readonly string[] = [ + ...new Set(LANES.map((entry) => entry.workflow)), +]; /** * The one method that must be reachable by no lane: `testCommand` is the runner's server @@ -186,6 +201,13 @@ function identifiers( ); } +/** The lane's platform's compiled identifiers, before its flags narrow them. */ +function compiledIdentifiersForLane(declaredTests: readonly DeclaredTest[], entry: Lane): string[] { + return declaredTests + .filter((test) => test.platforms.includes(entry.platform)) + .map((test) => test.identifier); +} + /** What one lane reaches: its platform's compiled set, narrowed by its flags. */ function laneReach( entry: Lane, @@ -195,13 +217,13 @@ function laneReach( ): Set { if (entry.selection === 'platform-branch') return new Set(iosPrTestIdentifiers(declaredTests, sharedPlatformBranchIds)); + const compiled = compiledIdentifiersForLane(declaredTests, entry); + if (entry.selection === 'named-only') { + const named = identifiers(flagged, entry.workflow, 'only-testing'); + return new Set(compiled.filter((id) => named.has(id))); + } const skipped = identifiers(flagged, entry.workflow, 'skip-testing'); - return new Set( - declaredTests - .filter((test) => test.platforms.includes(entry.platform)) - .map((test) => test.identifier) - .filter((id) => !skipped.has(id)), - ); + return new Set(compiled.filter((id) => !skipped.has(id))); } export function iosPrTestIdentifiers( @@ -382,6 +404,7 @@ export function counts(report: SelectionReport): { host: number; pr: number; nightly: number; + nightlyIpad: number; dark: number; } { return { @@ -389,6 +412,7 @@ export function counts(report: SelectionReport): { host: report.reach.host.size, pr: report.reach.pr.size, nightly: report.reach.nightly.size, + nightlyIpad: report.reach['nightly-ipad'].size, dark: report.dark.length, }; } @@ -467,12 +491,13 @@ export function reportFailures(report: SelectionReport): string[] { } export function formatSummary(report: SelectionReport): string { - const { declared, host, pr, nightly, dark } = counts(report); + const { declared, host, pr, nightly, nightlyIpad, dark } = counts(report); return ( `xctest selection: ${declared} declared ${report.target} methods — host lane ` + `(${HOST_XCTEST_SOURCE_FILE}, macOS, every PR) reaches ${host}, PR iOS branch lane (${PR_XCTEST_SOURCE_FILE}, ` + `iOS Simulator, selected PRs and main) selects ${pr}, nightly (${NIGHTLY_WORKFLOW_FILE}, iOS Simulator) ` + - `reaches ${nightly}; ${dark} reachable by no lane; ${ENTRY_POINT_METHOD} skipped everywhere.\n` + `reaches ${nightly}, its iPad leg selects ${nightlyIpad}; ${dark} reachable by no lane; ` + + `${ENTRY_POINT_METHOD} skipped everywhere.\n` ); } diff --git a/scripts/ios-snapshot-benchmark/size-install.test.ts b/scripts/ios-snapshot-benchmark/size-install.test.ts index 6791599692..84e230a482 100644 --- a/scripts/ios-snapshot-benchmark/size-install.test.ts +++ b/scripts/ios-snapshot-benchmark/size-install.test.ts @@ -59,6 +59,7 @@ test('clean-installed snapshot bridge validates all native assets when present', await writeFile(join(bridge, 'SnapshotBridgeRuntime.h'), 'native header'); await writeFile(join(bridge, 'SnapshotBridgeCapture.h'), 'capture header'); await writeFile(join(bridge, 'SnapshotBridgeCapture.m'), 'capture source'); + await writeFile(join(bridge, 'ForegroundApplicationGeometry.h'), 'shared rule'); assert.doesNotThrow(() => assertInstalledSnapshotBridge(root)); await rm(join(bridge, 'SnapshotBridgeRuntime.h')); assert.throws(() => assertInstalledSnapshotBridge(root), /SnapshotBridgeRuntime\.h/); diff --git a/scripts/package-apple-runner-source.mjs b/scripts/package-apple-runner-source.mjs index 68142937c2..8efdec713a 100644 --- a/scripts/package-apple-runner-source.mjs +++ b/scripts/package-apple-runner-source.mjs @@ -9,6 +9,9 @@ import { stripSwiftComments } from './strip-swift-comments.mjs'; export const UNIT_TEST_CONDITION = 'AGENT_DEVICE_RUNNER_UNIT_TESTS'; const SOURCE_DIR = path.join('apple', 'runner'); const OUTPUT_DIR = path.join('dist', 'apple', 'runner'); +// The UITests target finds the bridge's shared full-screen header through HEADER_SEARCH_PATHS, +// which resolves `apple/snapshot-bridge` from the runner project in both the checkout and the +// installed package (the package ships that directory at its root). Nothing is mirrored. const SNAPSHOT_PRESENTATION_SOURCE_DIR = path.join('apple', 'snapshot-presentation'); const SNAPSHOT_PRESENTATION_OUTPUT_DIR = path.join('dist', 'apple', 'snapshot-presentation'); const SNAPSHOT_PRESENTATION_RUNNER_MANIFEST = 'Package.runner.swift'; @@ -27,6 +30,9 @@ const SNAPSHOT_PRESENTATION_DEVELOPMENT_DIR_NAMES = new Set([ const LEGACY_OUTPUT_DIRS = [ path.join('dist', 'apple-runner'), path.join('dist', 'apple', 'apple-runner'), + // A short-lived build mirrored the shared header beside the packaged runner; the UITests target + // finds it through HEADER_SEARCH_PATHS now, so a leftover copy must not double-ship. + path.join('dist', 'apple', 'snapshot-bridge'), ]; const SKIPPED_DIR_NAMES = new Set(['.build', '.swiftpm', 'UnitTests', 'xcuserdata']); const SKIPPED_ROOT_FILES = new Set(['README.md', 'RUNNER_PROTOCOL.md']); @@ -203,6 +209,12 @@ function processIncludedEntry(entry, sourceDir, outputDir, relativePath, summary } function processFile(sourcePath, outputPath, relativePath, summary, options) { + if ( + options.trackNativeSources && + (path.extname(sourcePath) === '.m' || path.extname(sourcePath) === '.h') + ) { + summary.packagedNativeSourcePaths.push(outputPath ?? sourcePath); + } if (outputPath) { copyFile(sourcePath, outputPath, relativePath, summary, options); return; @@ -211,7 +223,8 @@ function processFile(sourcePath, outputPath, relativePath, summary, options) { } function copyFile(sourcePath, outputPath, relativePath, summary, options) { - if (path.extname(sourcePath) !== '.swift') { + const extension = path.extname(sourcePath); + if (extension !== '.swift') { fs.copyFileSync(sourcePath, outputPath); summary.copiedFiles += 1; return; diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index 94f0051852..fe55670eef 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -7,6 +7,7 @@ export const SNAPSHOT_BRIDGE_ASSET_PATHS = Object.freeze([ 'apple/snapshot-bridge/SnapshotBridgeRuntime.h', 'apple/snapshot-bridge/SnapshotBridgeCapture.h', 'apple/snapshot-bridge/SnapshotBridgeCapture.m', + 'apple/snapshot-bridge/ForegroundApplicationGeometry.h', ]); export function assertSnapshotBridgeAssets(presentPaths, context) { diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 909fe39c17..1e4b5a08a0 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -287,7 +287,7 @@ export const SYSTEM_COMMAND_OUTPUT_SCHEMAS = { const appStateCommandDescription = 'Show foreground app/activity (Android and HarmonyOS; iOS and macOS answer per session)'; const backCommandDescription = - 'Navigate back in the app or through system navigation. Use in-app for the app navigation stack and system when the platform back behavior is required. On a single-screen iPhone, an unreadable app tree can use a top-leading tap with before/after visual verification. A capture failure reports an unknown outcome.'; + 'Navigate back in the app or through system navigation. Use in-app for the app navigation stack and system when the platform back behavior is required. On iOS, a single-screen iPhone or an iPad whose app provably fills its screen can use a top-leading tap when the app tree is unreadable, with before/after visual verification. A capture failure reports an unknown outcome.'; const homeCommandDescription = 'Send the selected device to its home screen. This leaves the app session open but moves the foreground away from the app.'; const orientationCommandDescription = diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 6ba81a1b72..2d16403da3 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -118,7 +118,7 @@ iOS Simulator launch behavior: Back, orientation, and hardware buttons: - `back` uses app-owned back navigation: visible in-app back UI on Apple targets, and the back keyevent on Android. `--in-app` names this default explicitly. -- On a single-screen iPhone, `back` can use a top-leading tap when the app tree is unreadable. It compares the screen before and after the tap. If the screen cannot be captured, the command reports an unknown outcome; inspect the screen before you retry. +- On iOS, a single-screen iPhone or an iPad whose app provably fills its screen, `back` can use a top-leading tap when the app tree is unreadable. It compares the screen before and after the tap. If the screen cannot be captured, the command reports an unknown outcome; inspect the screen before you retry. - `back --system` sends system back input: the Android back keyevent, the iOS back gesture, or the tvOS Siri Remote Menu button. macOS has no system back, so it reports unavailable. - `orientation portrait|portrait-upside-down|landscape-left|landscape-right` works on iOS and Android mobile targets only. On ADB-backed Android it turns auto-rotate off, and the lock persists after the session ends until something turns it back on. - `orientation auto` hands rotation back to the sensor on ADB-backed Android, local or Limrun (`accelerometer_rotation=1`, `user_rotation` untouched), and reports `orientation: "auto"`. It does not wait for the display to rotate, because there is no target rotation: the sensor decides, and an emulator usually settles on portrait. Every other backend refuses `auto` with `UNSUPPORTED_OPERATION`.