Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
eb706bd
fix(ios): size the sole display from the system app, not the runner's…
thymikee Nov 2, 2026
f1e452f
fix(ios): log a failed system-app screen read and harden its test
thymikee Nov 2, 2026
b2bfca5
test(ios): read the expected screen from the host app, within the fil…
thymikee Nov 2, 2026
0fcd727
fix(ios): refuse a synthesized gesture when the sole display cannot b…
thymikee Nov 2, 2026
c3deb5d
fix(ios): log every failed system-app screen read and keep the hint k…
thymikee Nov 2, 2026
b81d3cc
docs(ios): say what the sole-screen route pays for after the display …
thymikee Nov 2, 2026
7ea0bb3
perf(ios): read the sole display size once per runner
thymikee Nov 2, 2026
a812073
fix(ios): keep the sole display size for one command, not the runner'…
thymikee Nov 2, 2026
a42f844
fix(ios): turn about a full-screen app's window when the display size…
thymikee Nov 2, 2026
d7c5a03
fix(ios): read the display box only for turns that move the axes
thymikee Nov 2, 2026
4314cf1
ci(ios): keep the nightly iPad leg at five tests
thymikee Nov 2, 2026
409188e
test(ios): share the AX-failure tap tests' launch, landscape and stub…
thymikee Nov 2, 2026
4690ea7
refactor(ios): name the sole-screen admission rule and the turns that…
thymikee Nov 2, 2026
3e2b177
test(ios): pin that a portrait tap dispatches when the display is unr…
thymikee Nov 2, 2026
5c470cb
docs(ios): place the display-size read after admission and spell long…
thymikee Nov 2, 2026
bf0b1ca
fix(ios): dispatch a window-less portrait tap when the display size i…
thymikee Nov 2, 2026
00d3643
test(ios): name exactly which turns read the display box
thymikee Nov 2, 2026
1fbe15b
fix(ios): read sole-display size from XCTest screen metrics
thymikee Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/xctest-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -169,6 +170,7 @@ jobs:
-xctestrun "$XCTESTRUN_PATH" \
-destination "platform=iOS Simulator,id=$UDID" \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSoleScreenDisplayIDIsNamedOnlyForAnAppThatFillsItsScreen \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testTapRefusesBeforeDispatchWhenTheSoleDisplayCannotBeMeasured \
Comment thread
thymikee marked this conversation as resolved.
Resolved
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testLongPressOnAnUnserializableTreeIsSynthesizedOnTheSoleScreen \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testInAppBackAfterARejectedQueryTapsThroughTheRouteThatOpens \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testInAppBackAfterARejectedQueryWithUnprovableForegroundTapsThroughTheWindow \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
thymikee marked this conversation as resolved.
Resolved
/// 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() {}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ extension RunnerTests {
alertDeadline: Date? = nil
) throws -> Response {
var activeApp = activeApp
mainOwned.soleDisplayPortraitSize = nil
Comment thread
thymikee marked this conversation as resolved.
Resolved
if command.invalidatesRememberedTextEntryTap {
clearRememberedTextEntryTap()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -205,7 +209,7 @@ extension RunnerTests {
}
if let message = RunnerSynthesizedGesture.synthesizeGesture(
withApplication: app,
resolvedWindow: context.resolvedWindow,
resolvedWindow: resolvedWindow,
pointerSamples: [samples]
) {
return .unsupported(
Expand Down Expand Up @@ -486,47 +490,160 @@ extension RunnerTests {
/// anything else (a windowed iPad, a multi-screen foldable) keeps requiring a window.
Comment thread
thymikee marked this conversation as resolved.
Resolved
@MainActor
Comment thread
thymikee marked this conversation as resolved.
Resolved
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"
)
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
Comment thread
thymikee marked this conversation as resolved.
Resolved
/// 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
Comment thread
thymikee marked this conversation as resolved.
Resolved

/// The gesture's name in the unresolved-window refusal, which predates this enum.
var unresolvedWindowName: String { self == .tap ? "tap" : "long press" }
}

Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
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)
Comment thread
thymikee marked this conversation as resolved.
Resolved
}
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 {
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
.unsupported(
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
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.
Comment thread
thymikee marked this conversation as resolved.
Resolved
static func soleScreenReferenceFrame(
nativePortraitSize size: CGSize,
portraitSize size: CGSize,
interfaceOrientation: Int
) -> CGRect? {
guard size.width > 0, size.height > 0 else { return nil }
Expand Down Expand Up @@ -678,6 +795,7 @@ extension RunnerTests {
: .sampled
}

@MainActor
func sampledPlannedGesture(
app: XCUIApplication,
plan: RunnerGesturePlan
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading