diff --git a/.github/workflows/xctest-nightly.yml b/.github/workflows/xctest-nightly.yml index cdecf78c38..8c7b4384ac 100644 --- a/.github/workflows/xctest-nightly.yml +++ b/.github/workflows/xctest-nightly.yml @@ -150,8 +150,9 @@ 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. + # the sole-screen rule — the admission boundary itself, the refusal when the display box a + # synthesized turn swaps cannot be read and the app is not proven full-screen (#4091), one + # hostile-tree command that must reach synthesis, and `back`'s route witness. # 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). @@ -169,6 +170,7 @@ jobs: -xctestrun "$XCTESTRUN_PATH" \ -destination "platform=iOS Simulator,id=$UDID" \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSoleScreenDisplayIDIsNamedOnlyForAnAppThatFillsItsScreen \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testTapRefusesBeforeDispatchWhenTheSoleDisplayCannotBeMeasured \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testLongPressOnAnUnserializableTreeIsSynthesizedOnTheSoleScreen \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testInAppBackAfterARejectedQueryTapsThroughTheRouteThatOpens \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testInAppBackAfterARejectedQueryWithUnprovableForegroundTapsThroughTheWindow \ diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerMainOwnedState.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerMainOwnedState.swift index 6c26c2077b..da6a98a004 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerMainOwnedState.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerMainOwnedState.swift @@ -9,6 +9,10 @@ final class RunnerMainOwnedState { var processIdentifier: Int? var accessibilityHealth: RunnerAccessibilityHealth = .unknown var needsPostSnapshotInteractionDelay = false + /// The device's only screen in portrait points, kept for the rest of the command after its first + /// successful read. Each command reads it again, because display scaling can change it while the + /// runner runs; orientation is applied per gesture. + var soleDisplayPortraitSize: CGSize? nonisolated init() {} } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 56041533a4..37616e1adf 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -10,6 +10,7 @@ extension RunnerTests { alertDeadline: Date? = nil ) throws -> Response { var activeApp = activeApp + mainOwned.soleDisplayPortraitSize = nil if command.invalidatesRememberedTextEntryTap { clearRememberedTextEntryTap() } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift index b0598958e5..7a20a5de0f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift @@ -28,7 +28,11 @@ extension RunnerTests { ) } let frame = context.referenceFrame - let rotationFrame = synthesizedRotationFrame(fallback: frame, interfaceOrientation: orientation) + guard + let rotationFrame = synthesizedRotationFrame(app: app, fallback: frame, interfaceOrientation: orientation) + else { + return Self.unmeasuredSoleDisplayOutcome(gesture: "drag") + } let start = CoordinateSpaceRotation.native( point: CGPoint(x: x, y: y), in: rotationFrame, @@ -104,28 +108,28 @@ extension RunnerTests { ) } let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app)) - guard let context = context ?? synthesizedCoordinateContext(app: app) else { - return .unsupported( - message: "synthesized coordinate tap could not resolve an app window with a finite screen frame", - hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates." - ) - } - let point = CoordinateSpaceRotation.native( - point: CGPoint(x: x, y: y), - in: synthesizedRotationFrame(fallback: context.referenceFrame, interfaceOrientation: orientation), - interfaceOrientation: orientation + let resolution = pointGestureTarget( + app: app, + x: x, + y: y, + orientation: orientation, + context: context ?? synthesizedCoordinateContext(app: app), + gesture: .tap ) + guard case .target(let point, let resolvedWindow, let referenceFrame) = resolution else { + return resolution.refusal + } logSynthesizedDispatch( kind: "tap", start: point, end: nil, - referenceFrame: context.referenceFrame, + referenceFrame: referenceFrame, orientation: orientation ) var message: NSString? let status = RunnerSynthesizedGesture.synthesizeTap( withApplication: app, - resolvedWindow: context.resolvedWindow, + resolvedWindow: resolvedWindow, x: Double(point.x), y: Double(point.y), deadline: deadline, @@ -177,23 +181,23 @@ extension RunnerTests { hint: "Retry with finite x and y values and a positive durationMs." ) } - guard let context else { - return .unsupported( - message: "synthesized coordinate long press could not resolve an app window with a finite screen frame", - hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates." - ) - } let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app)) - let point = CoordinateSpaceRotation.native( - point: CGPoint(x: x, y: y), - in: synthesizedRotationFrame(fallback: context.referenceFrame, interfaceOrientation: orientation), - interfaceOrientation: orientation + let resolution = pointGestureTarget( + app: app, + x: x, + y: y, + orientation: orientation, + context: context, + gesture: .longpress ) + guard case .target(let point, let resolvedWindow, let referenceFrame) = resolution else { + return resolution.refusal + } logSynthesizedDispatch( kind: "longPress", start: point, end: nil, - referenceFrame: context.referenceFrame, + referenceFrame: referenceFrame, orientation: orientation ) let samples: [[String: NSNumber]] = [0, durationMs].map { offsetMs in @@ -205,7 +209,7 @@ extension RunnerTests { } if let message = RunnerSynthesizedGesture.synthesizeGesture( withApplication: app, - resolvedWindow: context.resolvedWindow, + resolvedWindow: resolvedWindow, pointerSamples: [samples] ) { return .unsupported( @@ -486,14 +490,11 @@ extension RunnerTests { /// anything else (a windowed iPad, a multi-screen foldable) keeps requiring a window. @MainActor func soleScreenReferenceFrame(app: XCUIApplication) -> CGRect? { - var resolvedScreen: AnyObject? - var resolvedDisplayID: UInt = 0 - guard RunnerResolveSoleScreen(app, &resolvedScreen, &resolvedDisplayID) == nil else { - return nil + guard soleScreenRouteAdmits(app) else { return nil } + let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app)) + let frame = soleDisplayPortraitSize().flatMap { + Self.soleScreenReferenceFrame(portraitSize: $0, interfaceOrientation: orientation) } - let frame = soleDisplayFrame( - interfaceOrientation: Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app)) - ) NSLog( "AGENT_DEVICE_RUNNER_SYNTHESIZED_SOLE_SCREEN_ROUTE resolved=%@", frame.map { NSCoder.string(for: $0) } ?? "none" @@ -501,32 +502,148 @@ extension RunnerTests { return frame } + /// The sole-screen admission rule `RunnerResolveSoleScreen` owns: an iPhone by its idiom, an iPad + /// only on the fills-screen proof. + @MainActor + private func soleScreenRouteAdmits(_ app: XCUIApplication) -> Bool { + var resolvedScreen: AnyObject? + var resolvedDisplayID: UInt = 0 + return RunnerResolveSoleScreen(app, &resolvedScreen, &resolvedDisplayID) == nil + } + /// The box a synthesized turn swaps side lengths about. The digitizer turns about its display, so a /// placed iPad window's smaller box is not the basis (#3899); a multi-screen host keeps the - /// resolved window, which names its panel (ADR 0025). - func synthesizedRotationFrame(fallback: CGRect, interfaceOrientation: Int) -> CGRect { - soleDisplayFrame(interfaceOrientation: interfaceOrientation) ?? fallback + /// resolved window `fallback`, which names its panel (ADR 0025). When the device's only screen + /// will not report its size, `fallback` is the basis only while the app provably fills that + /// screen; otherwise this is nil and the gesture refuses before it dispatches. Portrait and an + /// unknown orientation leave the point as it is, so they keep `fallback` without a read; landscape + /// swaps the box's sides and upside-down mirrors the point across them, so both read it. + @MainActor + func synthesizedRotationFrame(app: XCUIApplication, fallback: CGRect, interfaceOrientation: Int) -> CGRect? { + guard XCUIScreen.screens.count == 1 else { return fallback } + switch interfaceOrientation { + case RunnerInterfaceOrientation.landscapeLeft, RunnerInterfaceOrientation.landscapeRight, + RunnerInterfaceOrientation.portraitUpsideDown: + break + default: + return fallback + } + guard let portraitSize = soleDisplayPortraitSize() else { + let routeAdmits = soleScreenRouteAdmits(app) + NSLog( + "AGENT_DEVICE_RUNNER_APP_SCREEN_UNRESOLVED stage=rotation-basis basis=%@", + routeAdmits ? "window" : "refused" + ) + return routeAdmits ? fallback : nil + } + return Self.soleScreenReferenceFrame(portraitSize: portraitSize, interfaceOrientation: interfaceOrientation) + ?? fallback } - private func soleDisplayFrame(interfaceOrientation: Int) -> CGRect? { - guard XCUIScreen.screens.count == 1 else { return nil } - let screen = UIScreen.main - let scale = screen.nativeScale - guard scale > 0 else { return nil } - return Self.soleScreenReferenceFrame( - nativePortraitSize: CGSize( - width: screen.nativeBounds.width / scale, - height: screen.nativeBounds.height / scale - ), - interfaceOrientation: interfaceOrientation + private enum SynthesizedPointGesture: String { + case tap + case longpress + + /// The gesture's name in the unresolved-window refusal, which predates this enum. + var unresolvedWindowName: String { self == .tap ? "tap" : "long press" } + } + + private enum PointGestureResolution { + /// A nil `resolvedWindow` routes the record to the sole screen. + case target(point: CGPoint, resolvedWindow: XCUIElement?, referenceFrame: CGRect) + case refused(RunnerInteractionOutcome) + + /// Read only on the branch that is not a target, so its `.performed` answer is never returned. + var refusal: RunnerInteractionOutcome { + if case .refused(let outcome) = self { return outcome } + return .performed + } + } + + /// Where a synthesized point gesture lands: its native point and the window that routes it, or the + /// refusal that stops it before dispatch. + @MainActor + private func pointGestureTarget( + app: XCUIApplication, + x: Double, + y: Double, + orientation: Int, + context: SynthesizedCoordinateContext?, + gesture: SynthesizedPointGesture + ) -> PointGestureResolution { + if let context { + guard + let rotationFrame = synthesizedRotationFrame( + app: app, + fallback: context.referenceFrame, + interfaceOrientation: orientation + ) + else { + return .refused(Self.unmeasuredSoleDisplayOutcome(gesture: gesture.rawValue)) + } + let point = CoordinateSpaceRotation.native( + point: CGPoint(x: x, y: y), + in: rotationFrame, + interfaceOrientation: orientation + ) + return .target(point: point, resolvedWindow: context.resolvedWindow, referenceFrame: context.referenceFrame) + } + if let unrotated = unrotatedSoleScreenPoint(app: app, x: x, y: y, interfaceOrientation: orientation) { + return .target(point: unrotated, resolvedWindow: nil, referenceFrame: .null) + } + return .refused( + .unsupported( + message: "synthesized coordinate \(gesture.unresolvedWindowName) could not resolve an app window with a finite screen frame", + hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates." + ) + ) + } + + /// A point gesture with no context: no window resolved and the sole screen's size is unreadable, + /// so there is no box (#4091). On an admitted sole screen a portrait turn leaves the point as it + /// is, so it is dispatched as given; any other turn needs the box and refuses. Nil refuses. + @MainActor + private func unrotatedSoleScreenPoint( + app: XCUIApplication, + x: Double, + y: Double, + interfaceOrientation: Int + ) -> CGPoint? { + guard XCUIScreen.screens.count == 1, soleScreenRouteAdmits(app) else { return nil } + let unrotated = interfaceOrientation == RunnerInterfaceOrientation.portrait + NSLog( + "AGENT_DEVICE_RUNNER_APP_SCREEN_UNRESOLVED stage=rotation-basis basis=%@ window=none", + unrotated ? "unrotated" : "refused" + ) + return unrotated ? CGPoint(x: x, y: y) : nil + } + + /// Not `UIScreen.main`: the runner process can read that in compatibility mode (#4091). A failed + /// read is not kept, so the next gesture reads again. + @MainActor + private func soleDisplayPortraitSize() -> CGSize? { + if let cached = mainOwned.soleDisplayPortraitSize { return cached } + let screen = RunnerSoleScreenSize() + guard screen.width > 0, screen.height > 0 else { return nil } + let size = CGSize(width: min(screen.width, screen.height), height: max(screen.width, screen.height)) + mainOwned.soleDisplayPortraitSize = size + return size + } + + static func unmeasuredSoleDisplayOutcome(gesture: String) -> RunnerInteractionOutcome { + .unsupported( + message: + "synthesized \(gesture) could not read the size of the device's only screen, and the app is not " + + "proven to fill it, so its coordinates cannot be rotated", + hint: "Retry after the app is foregrounded; the runner log records why the screen size could not be read." ) } #endif - /// The screen in the app's interface orientation, anchored at the origin like a window frame. An - /// unknown orientation cannot say which way to lay the screen, so it names no frame. + /// The screen, in points, in the app's interface orientation, anchored at the origin like a window + /// frame. An unknown orientation cannot say which way to lay the screen, so it names no frame. static func soleScreenReferenceFrame( - nativePortraitSize size: CGSize, + portraitSize size: CGSize, interfaceOrientation: Int ) -> CGRect? { guard size.width > 0, size.height > 0 else { return nil } @@ -678,6 +795,7 @@ extension RunnerTests { : .sampled } + @MainActor func sampledPlannedGesture( app: XCUIApplication, plan: RunnerGesturePlan @@ -692,7 +810,11 @@ extension RunnerTests { width: plan.viewport.width, height: plan.viewport.height ) - let rotationFrame = synthesizedRotationFrame(fallback: frame, interfaceOrientation: orientation) + guard + let rotationFrame = synthesizedRotationFrame(app: app, fallback: frame, interfaceOrientation: orientation) + else { + return Self.unmeasuredSoleDisplayOutcome(gesture: "gesture") + } let pointerSamples: [[[String: NSNumber]]] = plan.pointers.map { pointer in pointer.samples.map { sample in let point = CoordinateSpaceRotation.native( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h index cf357347d9..8e0c1c6b4c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h @@ -71,6 +71,13 @@ FOUNDATION_EXPORT NSString * _Nullable RunnerResolveSoleScreen( NSUInteger *displayID ); +// The sole XCTest display's pixel bounds divided by its scale, in points. Neither the runner's +// compatibility-mode `UIScreen` nor the system application's possibly empty AX frame supplies +// this measurement. Any failed read or a screen count other than one answers `CGSizeZero`. +#if TARGET_OS_IOS +FOUNDATION_EXPORT CGSize RunnerSoleScreenSize(void); +#endif + // 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) { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m index f16705845a..b32a285818 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m @@ -251,6 +251,37 @@ RunnerFullScreenEvidence RunnerFullScreenEvidenceFromForeground( return cached; } +CGSize RunnerSoleScreenSize(void) { + __block NSUInteger screenCount = 0; + __block CGRect bounds = CGRectNull; + __block CGFloat scale = 0; + NSString *exception = [RunnerObjCExceptionCatcher catchException:^{ + NSArray *screens = XCUIScreen.screens; + screenCount = screens.count; + if (screenCount != 1) return; + XCUIScreen *screen = screens.firstObject; + NSValue *boundsValue = [screen valueForKey:@"bounds"]; + [boundsValue getValue:&bounds size:sizeof(bounds)]; + NSNumber *scaleValue = [screen valueForKey:@"scale"]; + if ([scaleValue isKindOfClass:NSNumber.class]) scale = scaleValue.doubleValue; + }]; + if (exception != nil || screenCount != 1 || + !AgentDeviceForegroundFrameIsMeasurable(bounds) || !isfinite(scale) || scale <= 0) { + NSLog( + @"AGENT_DEVICE_RUNNER_APP_SCREEN_UNRESOLVED stage=sole-screen-size screens=%lu bounds=(%g,%g %gx%g) scale=%g raised=%@", + (unsigned long)screenCount, + bounds.origin.x, + bounds.origin.y, + bounds.size.width, + bounds.size.height, + scale, + exception != nil ? @"yes" : @"no" + ); + return CGSizeZero; + } + return CGSizeMake(bounds.size.width / scale, bounds.size.height / scale); +} + // One contained probe: it checks each selector before sending it and reads the target pid and both // frames inside one exception scope, so any failed read answers Unknown. The target pid comes from // the existing runner owner for that read (RunnerAXSnapshotBridge). @@ -321,8 +352,11 @@ static RunnerFullScreenEvidence RunnerProbeApplicationFillsScreen(id application ); 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 iPhone keeps every app full-screen, so its idiom answers the rule and admission never pays + // for the fills-screen probe. Off the phone idiom the route opens only on that proof. The display + // size is a separate XCTest screen read the caller makes after admission, the first time + // a command needs the box; a command keeps a successful read, and the next gesture retries a failed + // one. 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"; diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift index 7ee8056f4c..9ca703caee 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift @@ -26,7 +26,136 @@ private final class RunnerCoordinatePressCounter: NSObject { } } +/// Answers like the runner process's `UIScreen` in iPad compatibility mode, which the generated runner +/// app (no launch screen) can run in: 768x1024 whatever the device (#4091). It replaces the getters a +/// runner-process screen read takes: `bounds`, and `nativeBounds` over `nativeScale`. +private final class RunnerCompatibilityModeScreenStub: NSObject { + @objc var bounds: CGRect { CGRect(x: 0, y: 0, width: 768, height: 1024) } + @objc var nativeBounds: CGRect { CGRect(x: 0, y: 0, width: 1536, height: 2048) } + @objc var nativeScale: CGFloat { 2 } +} + +/// Physical iOS can expose no usable accessibility frame for the system application. +private final class RunnerUnreadableSystemApplicationStub: NSObject { + @objc class func systemApplication() -> AnyObject? { nil } +} + extension RunnerTests { + /// Installs `RunnerUnreadableSystemApplicationStub` on AXRuntime's `AXElement` until the returned + /// closure restores it. + private func simulateUnreadableSystemApplication() throws -> () -> Void { + _ = dlopen("/System/Library/PrivateFrameworks/AXRuntime.framework/AXRuntime", RTLD_NOW) + let element: AnyClass = try XCTUnwrap(NSClassFromString("AXElement")) + let selector = NSSelectorFromString("systemApplication") + let target = try XCTUnwrap(class_getClassMethod(element, selector)) + let stub = try XCTUnwrap(class_getClassMethod(RunnerUnreadableSystemApplicationStub.self, selector)) + let original = method_setImplementation(target, method_getImplementation(stub)) + return { method_setImplementation(target, original) } + } + + private func simulateUnreadableDisplay() throws -> () -> Void { + let selector = #selector(NSObject.value(forKey:)) + let inherited = try XCTUnwrap(class_getInstanceMethod(XCUIScreen.self, selector)) + class_addMethod(XCUIScreen.self, selector, method_getImplementation(inherited), method_getTypeEncoding(inherited)) + let target = try XCTUnwrap(class_getInstanceMethod(XCUIScreen.self, selector)) + let original = method_getImplementation(target) + typealias ReadValue = @convention(c) (AnyObject, Selector, NSString) -> AnyObject? + let readValue = unsafeBitCast(original, to: ReadValue.self) + let read: @convention(block) (AnyObject, NSString) -> AnyObject? = { screen, key in + if key == "scale" { return NSNumber(value: 0) } + return readValue(screen, selector, key) + } + let replacement = imp_implementationWithBlock(read) + method_setImplementation(target, replacement) + return { + method_setImplementation(target, original) + imp_removeBlock(replacement) + } + } + + /// Sets the requested orientation and waits for the host app's interface to follow. + @MainActor + private func rotateHostApp(landscape: Bool) throws -> () -> Void { + XCUIDevice.shared.orientation = landscape ? .landscapeLeft : .portrait + let restore = { XCUIDevice.shared.orientation = .portrait } + let expected = landscape + ? [RunnerInterfaceOrientation.landscapeLeft, RunnerInterfaceOrientation.landscapeRight] + : [RunnerInterfaceOrientation.portrait] + let deadline = Date().addingTimeInterval(5) + while !expected.contains(Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))), + Date() < deadline + { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let turned = expected.contains(Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))) + if !turned { restore() } + _ = try XCTUnwrap(turned ? true : nil, "the host app did not follow the requested orientation") + return restore + } + + /// The state the unreadable-display tap tests start from: the host app launched (turned to landscape when + /// asked), a tap context on its resolved window (measured at `windowFrame` when given) or, when + /// the window query is rejected, an unserializable tree and no context, and XCTest's display + /// read failing with no kept size. The returned cleanup undoes every step. + @MainActor + private func launchWithUnreadableDisplay( + landscape: Bool, + windowFrame: CGRect? = nil, + windowQueryRejected: Bool = false + ) throws -> (context: SynthesizedCoordinateContext?, cleanup: () -> Void) { + var cleanups: [() -> Void] = [] + let cleanup = { for step in cleanups.reversed() { step() } } + do { + app.launch() + mainOwned.app = app + cleanups.append { + self.invalidateCachedTarget(reason: "unit_test_cleanup") + self.app.terminate() + } + cleanups.append(try rotateHostApp(landscape: landscape)) + var context: SynthesizedCoordinateContext? + if !windowQueryRejected { + let resolved = try XCTUnwrap(resolveRunnerWindowElement(app: app)) + context = try XCTUnwrap( + SynthesizedCoordinateContext( + validating: windowFrame ?? resolved.frame, + resolvedWindow: resolved.window, + accessibilityHealth: .healthy + ) + ) + } + cleanups.append(try simulateUnreadableDisplay()) + if windowQueryRejected { cleanups.append(try simulateUnserializableAppTree()) } + // An earlier gesture may have kept a successful read; this one must reach XCTest. + mainOwned.soleDisplayPortraitSize = nil + return (context, cleanup) + } catch { + cleanup() + throw error + } + } + + /// Installs `RunnerCompatibilityModeScreenStub` until the returned closure restores UIKit. + private func simulateCompatibilityModeScreen() throws -> () -> Void { + let getters = [ + #selector(getter: UIScreen.bounds), + #selector(getter: UIScreen.nativeBounds), + #selector(getter: UIScreen.nativeScale), + ] + let resolved = try getters.map { getter in + ( + try XCTUnwrap(class_getInstanceMethod(UIScreen.self, getter)), + try XCTUnwrap(class_getInstanceMethod(RunnerCompatibilityModeScreenStub.self, getter)) + ) + } + let originals = resolved.map { target, stub in + (target, method_setImplementation(target, method_getImplementation(stub))) + } + return { + for (method, original) in originals.reversed() { method_setImplementation(method, original) } + } + } + /// Installs `RunnerUnserializableTreeStub` until the returned closure restores XCTest. private func simulateUnserializableAppTree() throws -> () -> Void { let swaps: [(Method?, Method?)] = [ @@ -104,6 +233,7 @@ extension RunnerTests { func testGestureViewportOnAnUnserializableTreeIsTheSoleScreen() throws { app.launch() mainOwned.app = app + let appFrame = app.frame let restoreXCTest = try simulateUnserializableAppTree() defer { restoreXCTest() @@ -118,11 +248,10 @@ extension RunnerTests { if soleScreenRouteIsOpen(for: app) { XCTAssertTrue(response.ok, String(describing: response.error)) - let screen = UIScreen.main.bounds 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(try XCTUnwrap(response.data?.x2), Double(appFrame.width), accuracy: 0.5) + XCTAssertEqual(try XCTUnwrap(response.data?.y2), Double(appFrame.height), accuracy: 0.5) } else { XCTAssertFalse(response.ok) XCTAssertEqual(response.error?.message, "Active app interaction viewport is unavailable") @@ -137,13 +266,13 @@ extension RunnerTests { let portrait = CGSize(width: 402, height: 874) for orientation in [RunnerInterfaceOrientation.portrait, RunnerInterfaceOrientation.portraitUpsideDown] { XCTAssertEqual( - Self.soleScreenReferenceFrame(nativePortraitSize: portrait, interfaceOrientation: orientation), + Self.soleScreenReferenceFrame(portraitSize: portrait, interfaceOrientation: orientation), CGRect(x: 0, y: 0, width: 402, height: 874) ) } for orientation in [RunnerInterfaceOrientation.landscapeLeft, RunnerInterfaceOrientation.landscapeRight] { XCTAssertEqual( - Self.soleScreenReferenceFrame(nativePortraitSize: portrait, interfaceOrientation: orientation), + Self.soleScreenReferenceFrame(portraitSize: portrait, interfaceOrientation: orientation), CGRect(x: 0, y: 0, width: 874, height: 402) ) } @@ -180,32 +309,217 @@ extension RunnerTests { XCTAssertEqual(displayID, 0) } + /// Physical iOS can report no system application's AX frame while its display is measurable. + @MainActor + func testSoleDisplaySizeDoesNotRequireASystemApplicationAccessibilityFrame() throws { + try XCTSkipUnless(XCUIScreen.screens.count == 1, "the rotation basis requires one display") + app.launch() + defer { + invalidateCachedTarget(reason: "unit_test_cleanup") + app.terminate() + } + try XCTSkipUnless(soleScreenRouteIsOpen(for: app), "the host app must fill the display") + let frame = app.frame + let restoreUIKit = try simulateCompatibilityModeScreen() + defer { restoreUIKit() } + let restoreAXRuntime = try simulateUnreadableSystemApplication() + defer { restoreAXRuntime() } + mainOwned.soleDisplayPortraitSize = nil + let placedWindow = CGRect(x: 224, y: 207, width: 386, height: 682) + let measured = try XCTUnwrap(synthesizedRotationFrame( + app: app, fallback: placedWindow, interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + )) + XCTAssertEqual(measured.origin, .zero) + XCTAssertEqual(measured.width, max(frame.width, frame.height), accuracy: 0.5) + XCTAssertEqual(measured.height, min(frame.width, frame.height), accuracy: 0.5) + } + /// #3899: a synthesized turn swaps the display's side lengths, never a placed window's smaller box. + /// #4091: the display is the one the launched host app fills, not the runner process's own + /// `UIScreen`, which the generated runner app (no launch screen) reads in compatibility mode. + @MainActor func testSynthesizedRotationFrameIsTheDisplayNotAPlacedWindow() throws { try XCTSkipUnless(XCUIScreen.screens.count == 1, "a multi-screen host keeps its panel's window box") + app.launch() + defer { + invalidateCachedTarget(reason: "unit_test_cleanup") + app.terminate() + } + try XCTSkipUnless(soleScreenRouteIsOpen(for: app), "the host app's frame is the display only while it fills it") + let appFrame = app.frame + let restoreUIKit = try simulateCompatibilityModeScreen() + defer { restoreUIKit() } let placedWindow = CGRect(x: 224, y: 207, width: 386, height: 682) - let screen = UIScreen.main.fixedCoordinateSpace.bounds.size + let screen = CGSize( + width: min(appFrame.width, appFrame.height), + height: max(appFrame.width, appFrame.height) + ) + XCTAssertNotEqual(screen, CGSize(width: 768, height: 1024), "the host app must not fill a 768x1024 screen") + // Portrait and an unknown orientation skip the read; landscape and upside-down read the box. + let expectations = [ + (RunnerInterfaceOrientation.portraitUpsideDown, screen), + (RunnerInterfaceOrientation.landscapeRight, CGSize(width: screen.height, height: screen.width)), + ] + for (orientation, expected) in expectations { + let frame = try XCTUnwrap(synthesizedRotationFrame(app: app, fallback: placedWindow, interfaceOrientation: orientation)) + XCTAssertEqual(frame.origin, .zero) + XCTAssertEqual(frame.width, expected.width, accuracy: 0.5) + XCTAssertEqual(frame.height, expected.height, accuracy: 0.5) + } + } + + /// A command keeps its first successful display size and later gestures take it even while the + /// display read would fail. The next command reads again, because + /// display scaling can change the size, and a failed read is not kept. + @MainActor + func testSoleDisplaySizeIsReadOncePerCommandAndAFailedReadIsNotKept() throws { + try XCTSkipUnless(XCUIScreen.screens.count == 1, "a multi-screen host keeps its panel's window box") + let placedWindow = CGRect(x: 224, y: 207, width: 386, height: 682) + let landscape = RunnerInterfaceOrientation.landscapeRight + mainOwned.soleDisplayPortraitSize = nil + let measured = try XCTUnwrap(synthesizedRotationFrame(app: app, fallback: placedWindow, interfaceOrientation: landscape)) + XCTAssertNotEqual(measured, placedWindow) + + let nextCommand = try runnerCommandFixture(#"{"command":"uptime","commandId":"next-command"}"#) + // Nothing between the install and the restore throws, so the restore always runs. + let restoreDisplay = try simulateUnreadableDisplay() + XCTAssertEqual(synthesizedRotationFrame(app: app, fallback: placedWindow, interfaceOrientation: landscape), measured) + _ = try? executeOnMainPrepared(command: nextCommand, activeApp: app) + XCTAssertNotEqual( + synthesizedRotationFrame(app: app, fallback: placedWindow, interfaceOrientation: landscape), + measured, + "the next command must read the size again rather than reuse the kept one" + ) + restoreDisplay() + XCTAssertEqual(synthesizedRotationFrame(app: app, fallback: placedWindow, interfaceOrientation: landscape), measured) + } + + /// #4091: a portrait turn leaves the point as it is, so an unreadable display box neither refuses + /// it nor is read for it: the tap is dispatched even through a placed window on either family. + @MainActor + func testPortraitTapDispatchesWhenTheSoleDisplayCannotBeMeasured() throws { + try XCTSkipUnless(XCUIScreen.screens.count == 1, "a multi-screen host keeps its panel's window box") + let placedWindow = CGRect(x: 224, y: 207, width: 386, height: 682) + let (context, cleanup) = try launchWithUnreadableDisplay(landscape: false, windowFrame: placedWindow) + defer { cleanup() } + XCTAssertEqual( + Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app)), + RunnerInterfaceOrientation.portrait + ) + + let outcome = synthesizedTapAt(app: app, x: 40, y: 40, context: context) + guard case .performed? = outcome else { + return XCTFail("expected a portrait tap to be dispatched, got \(String(describing: outcome))") + } + } + + /// #4091: with the window query rejected (a tree XCTest cannot serialize) and the display + /// unreadable there is no box at all. A portrait turn leaves the point as it is, so an admitted sole + /// screen still takes the tap as given. + @MainActor + func testWindowlessPortraitTapDispatchesWhenTheSoleDisplayCannotBeMeasured() throws { + try XCTSkipUnless(XCUIScreen.screens.count == 1, "a multi-screen host keeps its panel's window box") + let (_, cleanup) = try launchWithUnreadableDisplay(landscape: false, windowQueryRejected: true) + defer { cleanup() } + try XCTSkipUnless( + soleScreenRouteIsOpen(for: app), + "the sole-screen rule must admit the app; the landscape test covers refusal" + ) + XCTAssertEqual( + Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app)), + RunnerInterfaceOrientation.portrait + ) + + let outcome = synthesizedTapAt(app: app, x: 40, y: 40) + guard case .performed? = outcome else { + return XCTFail("expected a window-less portrait tap to be dispatched, got \(String(describing: outcome))") + } + } + + /// #4091: a landscape turn needs the box, and with the window query rejected no window can stand in + /// for an unreadable display, so the tap refuses before it dispatches instead of falling back to the + /// runner's own `UIScreen`. + @MainActor + func testWindowlessLandscapeTapRefusesWhenTheSoleDisplayCannotBeMeasured() throws { + try XCTSkipUnless(XCUIScreen.screens.count == 1, "a multi-screen host keeps its panel's window box") + let (_, cleanup) = try launchWithUnreadableDisplay(landscape: true, windowQueryRejected: true) + defer { cleanup() } + + let outcome = synthesizedTapAt(app: app, x: 40, y: 40) + guard case .unsupported(let message, _)? = outcome else { + return XCTFail("expected a refusal before dispatch, got \(String(describing: outcome))") + } + XCTAssertEqual(message, "synthesized coordinate tap could not resolve an app window with a finite screen frame") + } + + /// #4091: when the device's only screen will not report its size, an app the sole-screen rule + /// proves full-screen (an iPhone by its idiom) has a window that is the display, so its tap still + /// turns about that window and is dispatched. + @MainActor + func testTapTurnsAboutTheWindowWhenTheSoleDisplayCannotBeMeasuredOnAFullScreenApp() throws { + try XCTSkipUnless(XCUIScreen.screens.count == 1, "a multi-screen host keeps its panel's window box") + let (context, cleanup) = try launchWithUnreadableDisplay(landscape: true) + defer { cleanup() } + try XCTSkipUnless( + soleScreenRouteIsOpen(for: app), + "the app must provably fill the screen; the refusal test covers it" + ) + + let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app)) + let windowFrame = try XCTUnwrap(context).referenceFrame XCTAssertEqual( - synthesizedRotationFrame(fallback: placedWindow, interfaceOrientation: RunnerInterfaceOrientation.portrait), - CGRect(origin: .zero, size: screen) + synthesizedRotationFrame(app: app, fallback: windowFrame, interfaceOrientation: orientation), + windowFrame + ) + let outcome = synthesizedTapAt(app: app, x: 40, y: 40, context: context) + guard case .performed? = outcome else { + return XCTFail("expected the tap to be dispatched about the window, got \(String(describing: outcome))") + } + } + + /// #4091: when the device's only screen will not report its size and the app is not proven to fill + /// it, a placed window's smaller box is no basis to turn about (#3899), so a tap through a resolved + /// window refuses before it dispatches instead of landing off target. + @MainActor + func testTapRefusesBeforeDispatchWhenTheSoleDisplayCannotBeMeasured() throws { + try XCTSkipUnless(XCUIScreen.screens.count == 1, "a multi-screen host keeps its panel's window box") + let placedWindow = CGRect(x: 224, y: 207, width: 386, height: 682) + let (context, cleanup) = try launchWithUnreadableDisplay(landscape: true, windowFrame: placedWindow) + defer { cleanup() } + try XCTSkipIf( + soleScreenRouteIsOpen(for: app), + "an app proven full-screen turns about its window; the full-screen test covers it" + ) + + XCTAssertNil( + synthesizedRotationFrame(app: app, fallback: placedWindow, interfaceOrientation: RunnerInterfaceOrientation.landscapeRight) ) XCTAssertEqual( - synthesizedRotationFrame(fallback: placedWindow, interfaceOrientation: RunnerInterfaceOrientation.landscapeRight), - CGRect(x: 0, y: 0, width: screen.height, height: screen.width) + synthesizedRotationFrame(app: app, fallback: placedWindow, interfaceOrientation: RunnerInterfaceOrientation.portrait), + placedWindow, + "portrait skips the box read (only landscape and upside-down read it), so it must not refuse" ) + let outcome = synthesizedTapAt(app: app, x: 40, y: 40, context: context) + guard case .unsupported(let message, _)? = outcome else { + return XCTFail("expected a refusal before dispatch, got \(String(describing: outcome))") + } + guard case .unsupported(let refusal, _) = Self.unmeasuredSoleDisplayOutcome(gesture: "tap") else { + return XCTFail("the unmeasured-display outcome must be a refusal") + } + XCTAssertEqual(message, refusal) } #endif func testSoleScreenReferenceFrameRefusesAnUnknownOrientationOrAnEmptyScreen() { XCTAssertNil( Self.soleScreenReferenceFrame( - nativePortraitSize: CGSize(width: 402, height: 874), + portraitSize: CGSize(width: 402, height: 874), interfaceOrientation: RunnerInterfaceOrientation.unknown ) ) XCTAssertNil( Self.soleScreenReferenceFrame( - nativePortraitSize: .zero, + portraitSize: .zero, interfaceOrientation: RunnerInterfaceOrientation.portrait ) )