From cf0083616c4e4ee74f76070933d401f609a86646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E6=A1=82=E9=9B=84?= Date: Sun, 25 Oct 2026 18:21:53 +0800 Subject: [PATCH] fix(ios-runner): measure the sole-screen frame on the XCTest screen object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner process hosts no scene, so UIKit's UIScreen answers a placeholder panel there — 960x1440 @3x (320x480 points) on the nightly's iPhone 17 Pro — while the route gate and the capture path read the real XCUIScreen. A reference frame derived from UIScreen.main therefore aimed the window-less back fallback at (28, 56): the status bar, above the 62-106pt bar band the fallback exists to hit. The tap changed nothing, both #3894 nightly tests failed, and the windows route was skipped because the placeholder sole screen 'answered'. Probe evidence on this simulator (logged in the PR description): UIScreen.main.bounds = 320x480, XCUIScreen KVC bounds = 1206x2622 with KVC scale 3, and a public XCUIScreen screenshot at {402,874} @3x. The bridge grows RunnerResolveSoleScreenPanelGeometry: the same sole-screen rule as the routing resolver, fail-closed through the exception catcher with the shared APP_SCREEN_UNRESOLVED log, so a future Xcode that drops either private key refuses the route instead of aiming blind. The gestureViewport unit test now pins the route answer to that resolver, and a new test cross-checks the resolver against the public capture of the same screen. --- .../RunnerTests+SynthesizedInteraction.swift | 18 +++++---- .../RunnerXCTestEventBridge.h | 11 ++++++ .../RunnerXCTestEventBridge.m | 39 +++++++++++++++++++ ...nerTests+SynthesizedInteractionTests.swift | 32 +++++++++++++-- 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift index cde5f867c0..d5f93bfb60 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift @@ -524,17 +524,19 @@ extension RunnerTests { /// that can show an app in part of a screen (iPad, more than one screen) keeps requiring a window. @MainActor func soleScreenReferenceFrame(app: XCUIApplication) -> CGRect? { - guard UIDevice.current.userInterfaceIdiom == .phone, XCUIScreen.screens.count == 1 else { + guard UIDevice.current.userInterfaceIdiom == .phone else { + return nil + } + var pixelSize = CGSize.zero + var scale = CGFloat(0) + // UIKit's UIScreen answers this process's placeholder panel (320x480 points on a 402x874 + // device) because the runner hosts no scene, so the frame is measured on the XCTest screen + // object the route gate trusts (#3943). + guard RunnerResolveSoleScreenPanelGeometry(&pixelSize, &scale) == nil, scale > 0 else { return nil } - let screen = UIScreen.main - let scale = screen.nativeScale - guard scale > 0 else { return nil } let frame = Self.soleScreenReferenceFrame( - nativePortraitSize: CGSize( - width: screen.nativeBounds.width / scale, - height: screen.nativeBounds.height / scale - ), + nativePortraitSize: CGSize(width: pixelSize.width / scale, height: pixelSize.height / scale), interfaceOrientation: Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app)) ) NSLog( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h index 23649dea38..5f33683034 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h @@ -90,6 +90,17 @@ FOUNDATION_EXPORT NSString * _Nullable RunnerResolveSoleScreen( NSUInteger *displayID ); +// Reads the sole screen's panel size in pixels and its points-per-pixel scale, under the same +// sole-screen rule the gesture route uses. The runner process hosts no scene, so UIKit's UIScreen +// answers a placeholder panel (320x480 points on a 402x874 device) and a frame derived from it +// aims synthesized gestures at the status bar (#3943); the XCTest screen object carries the real +// panel. Private KVC raises rather than returns nil when XCTest has no such value, so both reads +// are guarded here and surface as a message instead of an exception reaching the caller. +FOUNDATION_EXPORT NSString * _Nullable RunnerResolveSoleScreenPanelGeometry( + CGSize *pixelSize, + CGFloat *pointsPerPixelScale +); + 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..bf0535695a 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m @@ -259,6 +259,45 @@ BOOL RunnerResolveApplicationScreen( return nil; } +NSString * _Nullable RunnerResolveSoleScreenPanelGeometry( + CGSize *pixelSize, + CGFloat *pointsPerPixelScale +) { + *pixelSize = CGSizeZero; + *pointsPerPixelScale = 0; +#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"; + } +#else + return @"private XCTest event synthesis unavailable: no resolved application window"; +#endif + __block CGSize readSize = CGSizeZero; + __block CGFloat readScale = 0; + NSString *exception = [RunnerObjCExceptionCatcher catchException:^{ + NSArray *screens = XCUIScreen.screens; + if (screens.count != 1) return; + CGRect bounds = CGRectZero; + [[screens.firstObject valueForKey:@"bounds"] getValue:&bounds size:sizeof(bounds)]; + readSize = bounds.size; + readScale = [[screens.firstObject valueForKey:@"scale"] doubleValue]; + }]; + if (exception != nil || readScale <= 0 || readSize.width <= 0 || readSize.height <= 0) { + NSLog( + @"AGENT_DEVICE_RUNNER_APP_SCREEN_UNRESOLVED stage=sole-screen-geometry size=(%g,%g) scale=%g raised=%@", + readSize.width, + readSize.height, + readScale, + exception != nil ? @"yes" : @"no" + ); + return @"private XCTest event synthesis unavailable: the sole screen did not report its panel geometry"; + } + *pixelSize = readSize; + *pointsPerPixelScale = readScale; + return nil; +} + NSString * _Nullable RunnerResolveWindowDisplayID(id window, NSUInteger *displayID) { __block CGRect frame = CGRectNull; NSString *frameException = [RunnerObjCExceptionCatcher catchException:^{ diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift index c056141e8c..dfaf37a227 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift @@ -121,11 +121,17 @@ extension RunnerTests { if showsAppsOnlyFullScreen { XCTAssertTrue(response.ok, String(describing: response.error)) - let screen = UIScreen.main.bounds + // The route answers the panel the XCTest screen object reports, not this + // process's UIScreen.main placeholder panel. + var pixelSize = CGSize.zero + var scale = CGFloat(0) + guard RunnerResolveSoleScreenPanelGeometry(&pixelSize, &scale) == nil, scale > 0 else { + return XCTFail("the sole screen did not report its panel geometry") + } XCTAssertEqual(response.data?.x, 0) XCTAssertEqual(response.data?.y, 0) - XCTAssertEqual(response.data?.x2, Double(screen.width)) - XCTAssertEqual(response.data?.y2, Double(screen.height)) + XCTAssertEqual(response.data?.x2, pixelSize.width / scale) + XCTAssertEqual(response.data?.y2, pixelSize.height / scale) } else { XCTAssertFalse(response.ok) XCTAssertEqual(response.error?.message, "Active app interaction viewport is unavailable") @@ -167,6 +173,26 @@ extension RunnerTests { XCTAssertEqual(displayID, 0) } } + + /// The panel geometry is the number every window-less synthesized aim divides by, so it is pinned + /// against the one public panel answer in this process: a capture of the same screen, whose image + /// carries its point size and its scale. UIKit's UIScreen cannot arbitrate — in the runner process + /// it answers a placeholder panel — so a drift here aims every fallback tap at the wrong panel. + func testSoleScreenPanelGeometryMatchesTheCapturedPanel() throws { + var pixelSize = CGSize.zero + var scale = CGFloat(0) + if UIDevice.current.userInterfaceIdiom != .phone || XCUIScreen.screens.count != 1 { + XCTAssertNotNil(RunnerResolveSoleScreenPanelGeometry(&pixelSize, &scale)) + return + } + XCTAssertNil(RunnerResolveSoleScreenPanelGeometry(&pixelSize, &scale)) + guard scale > 0, pixelSize.width > 0, pixelSize.height > 0 else { + return XCTFail("the sole screen reported unusable panel geometry") + } + let captured = try XCTUnwrap(XCUIScreen.screens[0].screenshot().image) + XCTAssertEqual(pixelSize.width / scale, captured.size.width, accuracy: 1) + XCTAssertEqual(pixelSize.height / scale, captured.size.height, accuracy: 1) + } #endif func testSoleScreenReferenceFrameRefusesAnUnknownOrientationOrAnEmptyScreen() {