Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -217,12 +217,11 @@ extension RunnerTests {
}

let rootFrame = privateAXRect(root["frame"])
let geometry = privateAXSnapshotGeometry(
let viewport = privateAXSnapshotViewport(
app: app,
bundleId: target.bundleId,
rootFrame: rootFrame
)
let viewport = geometry.viewport
let nodes = privateAXAcquisition(
rawRoot: root,
hint: hint
Expand Down Expand Up @@ -259,8 +258,7 @@ extension RunnerTests {
customActions: Self.privateAXCustomActionCoverage(
response[RunnerAXSnapshotCustomActionsKey]
),
viewport: viewport,
interfaceOrientation: geometry.interfaceOrientation
viewport: viewport
)
#else
return nil
Expand All @@ -275,37 +273,32 @@ extension RunnerTests {
!hasAbandonedMainThreadWork() && !isSnapshotXCTestChannelPenalized(bundleId: bundleId)
}

/// The geometry this tier may anchor a rotation on. The bridge's own root frame is one more
/// reported box rather than the app's frame, so a capture anchored on it reports no interface
/// orientation and normalizes nothing: rotated system surfaces then stay as reported, which the
/// consumers already treat as geometry they cannot measure (#2612).
private func privateAXSnapshotGeometry(
/// The app's reported viewport when XCTest can read it, else the bridge's own root frame declared
/// `.derived`, which cannot anchor a rotation: rotated system surfaces then stay as reported (#2612).
private func privateAXSnapshotViewport(
app: XCUIApplication,
bundleId: String?,
rootFrame: CGRect
) -> (viewport: CGRect, interfaceOrientation: Int) {
let fallback = rootFrame.isEmpty ? CGRect.infinite : rootFrame
) -> SnapshotViewport {
let fallback = SnapshotViewport.derived(box: rootFrame)
guard shouldReadPrivateAXViewportViaXCTest(bundleId: bundleId) else {
return (fallback, RunnerInterfaceOrientation.unknown)
return fallback
}
do {
let anchor = try runMainThreadWork(
let reported = try runMainThreadWork(
"private_ax_viewport",
timeout: 1,
timeoutError: snapshotMainThreadTimeoutError("reading private AX viewport")
) {
(
viewport: self.safeSnapshotViewport(app: app),
interfaceOrientation: self.capturedInterfaceOrientation(app: app)
)
self.safeSnapshotViewport(app: app, readingOrientation: true)
}
if anchor.viewport.isInfinite || anchor.viewport.isNull || anchor.viewport.isEmpty {
return (fallback, RunnerInterfaceOrientation.unknown)
if case .missing = reported {
return fallback
}
return (anchor.viewport, anchor.interfaceOrientation)
return reported
} catch {
NSLog("AGENT_DEVICE_RUNNER_PRIVATE_AX_VIEWPORT_FALLBACK=%@", String(describing: error))
return (fallback, RunnerInterfaceOrientation.unknown)
return fallback
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AgentDeviceSnapshotPresentation
import XCTest

extension RunnerTests {
Expand Down Expand Up @@ -120,16 +121,9 @@ extension RunnerTests {
return navigationBackKeywords.firstIndex { text.contains($0) }
}

// isFinite/>0 alone don't reject CGRect.infinite — its origin (~-9e307) is finite.
static func isUsableNavigationFrame(_ frame: CGRect) -> Bool {
guard frame.width.isFinite, frame.height.isFinite, frame.width > 0, frame.height > 0 else {
return false
}
return !frame.isInfinite
}

static func isTopNavigationControlFrame(_ candidate: CGRect, in window: CGRect) -> Bool {
guard isUsableNavigationFrame(candidate), isUsableNavigationFrame(window) else {
guard SnapshotGeometry.isPositiveFinite(candidate), SnapshotGeometry.isPositiveFinite(window)
else {
return false
}
// Accept the compact navigation/search header band without matching deep content controls.
Expand All @@ -138,7 +132,7 @@ extension RunnerTests {
}

static func topLeadingNavigationFallbackPoint(in frame: CGRect) -> CGPoint? {
guard isUsableNavigationFrame(frame) else {
guard SnapshotGeometry.isPositiveFinite(frame) else {
return nil
}
// Aim at the standard leading navigation slot, bounded for compact and tablet widths.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ extension RunnerTests {
struct SnapshotTraversalContext {
let queryRoot: XCUIElement
let rootSnapshot: XCUIElementSnapshot
let viewport: CGRect
/** Which way the app's interface is turned from the device's native space (#2612). */
let interfaceOrientation: Int
/** Carries which way the app's interface is turned from the device's native space (#2612). */
let viewport: SnapshotViewport
/**
* The keyboard band this capture measured, published beside the tree so the daemon's tap guard
* measures against the producer's own reading rather than a band it derives from these rects
Expand Down Expand Up @@ -278,8 +277,7 @@ extension RunnerTests {
nodes: nodes,
truncated: false,
effectiveDepth: nil,
viewport: context.viewport,
interfaceOrientation: context.interfaceOrientation
viewport: context.viewport
)
}

Expand Down Expand Up @@ -435,8 +433,7 @@ extension RunnerTests {
nodes: nodes,
truncated: false,
effectiveDepth: nil,
viewport: context.viewport,
interfaceOrientation: context.interfaceOrientation
viewport: context.viewport
)
}

Expand All @@ -455,14 +452,13 @@ extension RunnerTests {
nodes: nodes,
truncated: false,
effectiveDepth: nil,
viewport: .infinite,
interfaceOrientation: RunnerInterfaceOrientation.unknown
viewport: .missing(reason: .notProvided)
),
.completed
)
}

let viewport = safeSnapshotViewport(app: app)
let viewport = safeSnapshotViewport(app: app, readingOrientation: false)
var seen = Set<String>()
var candidates: [RawAXNode] = []
let flatElements = flatInteractiveElements(app: app, deadline: deadline)
Expand Down Expand Up @@ -492,11 +488,9 @@ extension RunnerTests {
}

// The synthetic root doubles as the daemon's viewport (find.ts prefers on-screen matches
// inside nodes[0].rect): use the real screen viewport when capture produced a finite one,
// so off-screen candidates can never inflate the root and masquerade as on-screen.
let rootRect = viewport.isInfinite || viewport.isNull || viewport.isEmpty
? interactiveRootFrame(for: candidates)
: viewport
// inside nodes[0].rect): use the real screen viewport when the capture resolved one, so
// off-screen candidates can never inflate the root and masquerade as on-screen.
let rootRect = viewport.rect ?? interactiveRootFrame(for: candidates)
nodes[0] = interactiveRootNode(rect: rootRect)
for candidate in candidates {
nodes.append(
Expand Down Expand Up @@ -524,8 +518,7 @@ extension RunnerTests {
nodes: nodes,
truncated: outcome == .deadlineExhausted,
effectiveDepth: nil,
viewport: viewport,
interfaceOrientation: RunnerInterfaceOrientation.unknown
viewport: viewport
),
outcome
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,13 @@ extension RunnerTests {
// The viewport and the interface orientation are one hop: geometry that arrives in the device's
// native space can only be placed relative to the app's own frame and rotation, and asking for
// the pair twice would read them at two different moments of a rotation.
let geometry = try runMainThreadWork(
let viewport = try runMainThreadWork(
"snapshot_viewport",
timeout: min(1.0, max(0.1, captureDeadline.timeIntervalSinceNow)),
timeoutError: snapshotMainThreadTimeoutError("preparing tree snapshot")
) {
(
viewport: self.safeSnapshotViewport(app: app),
interfaceOrientation: self.capturedInterfaceOrientation(app: app)
)
self.safeSnapshotViewport(app: app, readingOrientation: true)
}
let viewport = geometry.viewport
let interfaceOrientation = geometry.interfaceOrientation
let treeSliceBudget = treeCaptureSliceBudgetOverride ?? treeCaptureSliceBudget
let slice = min(treeSliceBudget, max(0.5, captureDeadline.timeIntervalSinceNow))
guard let rootSnapshot = try captureSnapshotRootBounded(app, sliceSeconds: slice) else {
Expand All @@ -70,7 +65,6 @@ extension RunnerTests {
queryRoot: app,
rootSnapshot: rootSnapshot,
viewport: viewport,
interfaceOrientation: interfaceOrientation,
keyboardBand: keyboardBand
)
}
Expand Down Expand Up @@ -140,8 +134,18 @@ extension RunnerTests {
return nil
}

func safeSnapshotViewport(app: XCUIApplication) -> CGRect {
safely("SNAPSHOT_VIEWPORT", CGRect.infinite) { snapshotViewport(app: app) }
/// The viewport as a declared fact; a read that raises is `.missing(reason: .notProvided)` (#2891).
/// `readingOrientation` reads the interface orientation in the same hop, for tiers whose frames can
/// arrive in the device's native space; without it the viewport cannot anchor a rotation.
func safeSnapshotViewport(app: XCUIApplication, readingOrientation: Bool) -> SnapshotViewport {
safely("SNAPSHOT_VIEWPORT", .missing(reason: .notProvided)) {
.reported(
box: snapshotAppFrame(app: app),
interfaceOrientation: readingOrientation
? capturedInterfaceOrientation(app: app)
: RunnerInterfaceOrientation.unknown
)
}
}

private func describeSnapshotError(_ error: Error) -> String {
Expand Down Expand Up @@ -231,16 +235,12 @@ extension RunnerTests {
return text.isEmpty ? nil : text
}

private func snapshotViewport(app: XCUIApplication) -> CGRect {
private func snapshotAppFrame(app: XCUIApplication) -> CGRect {
#if os(iOS)
let appFrame = onScreenWindowFrame(app: app)
return onScreenWindowFrame(app: app)
#else
let appFrame = app.frame
return app.frame
#endif
if !appFrame.isNull && !appFrame.isEmpty {
return appFrame
}
return .infinite
}

static func snapshotTraversalIdentity(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,8 +513,7 @@ extension RunnerTests {
let normalizedAcquisition = acquisition.replacingNodes(
SnapshotGeometrySpace.normalized(
nodes: acquisition.nodes,
viewport: acquisition.viewport,
interfaceOrientation: acquisition.interfaceOrientation
viewport: acquisition.viewport
)
)

Expand Down Expand Up @@ -641,7 +640,7 @@ extension RunnerTests {
guard Self.structuralOnlyNodeTypes.contains(node.type) else { return false }
guard !isRootContainer else { return true }

let isFullScreenContainer = !node.hittable && rootRects.contains { rootRect in
let isFullScreenContainer = node.hittable != true && rootRects.contains { rootRect in
rootRect.x == node.rect.x && rootRect.y == node.rect.y
&& rootRect.width == node.rect.width && rootRect.height == node.rect.height
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -464,15 +464,14 @@ extension RunnerTests {
interactiveOnly: true, customActions: false)
let acquired = SnapshotGeometrySpace.normalized(
nodes: privateAXAcquisition(rawRoot: tree, hint: hint),
viewport: viewport,
interfaceOrientation: RunnerInterfaceOrientation.portrait
viewport: .reported(box: viewport, interfaceOrientation: RunnerInterfaceOrientation.portrait)
)
// Acquisition serializes the drawer too; the shared fold is what hides it (#1797).
XCTAssertTrue(acquired.compactMap(\.label).contains("Admin settings"))

let capture = try SnapshotPresentation.presentRegular(
SnapshotAcquisition(
hint: hint, nodes: acquired, truncated: false, effectiveDepth: nil, viewport: viewport),
hint: hint, nodes: acquired, truncated: false, effectiveDepth: nil, viewport: .reported(box: viewport)),
options: PresentationOptions(interactiveOnly: true, depth: nil, scope: nil, raw: false),
policy: .cursorProjected
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ extension RunnerTests {
interfaceOrientation: RunnerInterfaceOrientation.portrait)
return try SnapshotPresentation.presentRegular(
SnapshotAcquisition(
hint: hint, nodes: nodes, truncated: false, effectiveDepth: nil, viewport: viewport,
interfaceOrientation: RunnerInterfaceOrientation.portrait),
hint: hint, nodes: nodes, truncated: false, effectiveDepth: nil,
viewport: .reported(
box: viewport,
interfaceOrientation: RunnerInterfaceOrientation.portrait
)),
options: PresentationOptions(
interactiveOnly: interactiveOnly, depth: nil, scope: nil, raw: false),
policy: .cursorProjected
Expand All @@ -59,8 +62,7 @@ extension RunnerTests {
) -> [RawAXNode] {
SnapshotGeometrySpace.normalized(
nodes: privateAXAcquisition(rawRoot: rawRoot, hint: hint),
viewport: viewport,
interfaceOrientation: interfaceOrientation
viewport: .reported(box: viewport, interfaceOrientation: interfaceOrientation)
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,7 @@ extension RunnerTests {
XCTAssertFalse(app.frame.isEmpty)
// The traversal context reads the viewport under a 1 s cap; a cold first read can overrun it
// and abandon the wrong block, so pay it here, uncapped.
_ = safeSnapshotViewport(app: app)
_ = capturedInterfaceOrientation(app: app)
_ = safeSnapshotViewport(app: app, readingOrientation: true)
currentApp = app
currentBundleId = "com.callstack.agentdevice.runner.tree-capture-test"
snapshotXCTestPenaltyWarmupExemption.isPending = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ extension RunnerTests {
nodes: [],
truncated: false,
effectiveDepth: nil,
viewport: .infinite
viewport: .reported(box: CGRect(x: 0, y: 0, width: 402, height: 874))
),
options: options
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ extension RunnerTests {
nodes: nodes,
truncated: false,
effectiveDepth: nil,
viewport: CGRect(x: 0, y: 0, width: 100, height: 100)
viewport: .reported(box: CGRect(x: 0, y: 0, width: 100, height: 100))
),
options: options
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ extension RunnerTests {
},
truncated: false,
effectiveDepth: nil,
viewport: fixture.viewport.cgRect
viewport: .reported(box: fixture.viewport.cgRect)
)
let options = PresentationOptions(
interactiveOnly: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ extension RunnerTests {
func testEffectiveGeometryIntersectsViewportAndAncestorClip() {
let effective = SnapshotGeometry.effectiveFrame(
reportedFrame: CGRect(x: 350, y: 80, width: 100, height: 100),
viewport: CGRect(x: 0, y: 0, width: 402, height: 874),
viewport: .reported(box: CGRect(x: 0, y: 0, width: 402, height: 874)),
ancestorClip: CGRect(x: 300, y: 100, width: 80, height: 80)
)

Expand All @@ -17,7 +17,7 @@ extension RunnerTests {
let reported = CGRect(x: 500, y: 120, width: 100, height: 44)
let effective = SnapshotGeometry.effectiveFrame(
reportedFrame: reported,
viewport: CGRect(x: 0, y: 0, width: 402, height: 874),
viewport: .reported(box: CGRect(x: 0, y: 0, width: 402, height: 874)),
ancestorClip: nil
)

Expand Down
Loading
Loading