From f048f88281cd8ccb0153059e7a7416e31fec8076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 14:49:35 +0200 Subject: [PATCH 1/7] fix(ios): carry the snapshot viewport across the runner boundary as a declared fact The runner spelled "viewport unknown" as CGRect.infinite, which SnapshotGeometry.isGeometricallyActionable read as "everything is actionable" before computing any center, while the host-AX path models the same state as IosViewportEvidence and declines to publish the bit. One state, two encodings, opposite directions, across a language boundary. SnapshotViewport now carries the three cases the host already uses -- reported, derived, and missing { reason } -- and refuses an unusable box at declaration, so no caller has to re-check what it was handed. The unknown-viewport policy is stated once, at the Swift decision site, and fails CLOSED: no box means no supportable claim about where a tap lands. Clipping stays a separate question from containment; a capture with no box has no clip and no root clip for the cumulative invariant to violate, which is an absent answer rather than an unbounded one. --- .../RunnerTests+AXSnapshotFallback.swift | 15 ++--- .../RunnerTests+Snapshot.swift | 12 ++-- .../RunnerTests+SnapshotAcquisition.swift | 13 ++--- .../RunnerTests+AXSnapshotFallbackTests.swift | 4 +- ...nnerTests+PrivateAXPresentationTests.swift | 5 +- ...RunnerTests+SnapshotCapturePlanTests.swift | 2 +- ...RunnerTests+SnapshotHittabilityTests.swift | 2 +- ...SnapshotPresentationConformanceTests.swift | 2 +- ...ts+SnapshotPresentationGeometryTests.swift | 4 +- ...s+SnapshotPresentationInvariantTests.swift | 10 ++-- ...unnerTests+SnapshotPresentationTests.swift | 32 ++++++----- ...nerTests+SnapshotVisibilityFoldTests.swift | 6 +- .../SnapshotCoordinateSpace.swift | 8 ++- .../SnapshotGeometry.swift | 57 ++++++++++++++++--- .../SnapshotModels.swift | 50 +++++++++++++++- .../SnapshotPresentationInvariant.swift | 27 ++++++--- .../SnapshotVisibilityFold.swift | 4 +- .../main.swift | 6 +- .../ConformanceTests.swift | 2 +- .../CoordinateSpaceTests.swift | 13 +++-- .../InvariantTests.swift | 2 +- .../RegularDepthTests.swift | 2 +- .../adr/0004-ios-snapshot-backend-strategy.md | 13 +++++ 23 files changed, 207 insertions(+), 84 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift index 13244aeaf2..f13899fca7 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift @@ -275,16 +275,17 @@ 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). + /// The geometry this tier may anchor a rotation on. The bridge's own root frame is declared + /// `.derived` rather than reported — it is a box this capture inferred for itself, not 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( app: XCUIApplication, bundleId: String?, rootFrame: CGRect - ) -> (viewport: CGRect, interfaceOrientation: Int) { - let fallback = rootFrame.isEmpty ? CGRect.infinite : rootFrame + ) -> (viewport: SnapshotViewport, interfaceOrientation: Int) { + let fallback = SnapshotViewport.derived(box: rootFrame) guard shouldReadPrivateAXViewportViaXCTest(bundleId: bundleId) else { return (fallback, RunnerInterfaceOrientation.unknown) } @@ -299,7 +300,7 @@ extension RunnerTests { interfaceOrientation: self.capturedInterfaceOrientation(app: app) ) } - if anchor.viewport.isInfinite || anchor.viewport.isNull || anchor.viewport.isEmpty { + if anchor.viewport.rect == nil { return (fallback, RunnerInterfaceOrientation.unknown) } return (anchor.viewport, anchor.interfaceOrientation) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index 3d469bf868..1d8549df0c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -21,7 +21,7 @@ extension RunnerTests { struct SnapshotTraversalContext { let queryRoot: XCUIElement let rootSnapshot: XCUIElementSnapshot - let viewport: CGRect + let viewport: SnapshotViewport /** Which way the app's interface is turned from the device's native space (#2612). */ let interfaceOrientation: Int /** @@ -455,7 +455,7 @@ extension RunnerTests { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: .infinite, + viewport: .missing(reason: .notProvided), interfaceOrientation: RunnerInterfaceOrientation.unknown ), .completed @@ -492,11 +492,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( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift index 26572f56fa..d0063268be 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift @@ -140,8 +140,10 @@ 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 leaves the capture with no box, which is + /// `.missing(reason: .notProvided)` and not a box that contains everything (#2891). + func safeSnapshotViewport(app: XCUIApplication) -> SnapshotViewport { + safely("SNAPSHOT_VIEWPORT", .missing(reason: .notProvided)) { snapshotViewport(app: app) } } private func describeSnapshotError(_ error: Error) -> String { @@ -231,16 +233,13 @@ extension RunnerTests { return text.isEmpty ? nil : text } - private func snapshotViewport(app: XCUIApplication) -> CGRect { + private func snapshotViewport(app: XCUIApplication) -> SnapshotViewport { #if os(iOS) let appFrame = onScreenWindowFrame(app: app) #else let appFrame = app.frame #endif - if !appFrame.isNull && !appFrame.isEmpty { - return appFrame - } - return .infinite + return .reported(box: appFrame) } static func snapshotTraversalIdentity( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXSnapshotFallbackTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXSnapshotFallbackTests.swift index 82f6bec10b..3400efb95d 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXSnapshotFallbackTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXSnapshotFallbackTests.swift @@ -464,7 +464,7 @@ extension RunnerTests { interactiveOnly: true, customActions: false) let acquired = SnapshotGeometrySpace.normalized( nodes: privateAXAcquisition(rawRoot: tree, hint: hint), - viewport: viewport, + viewport: .reported(rect: viewport), interfaceOrientation: RunnerInterfaceOrientation.portrait ) // Acquisition serializes the drawer too; the shared fold is what hides it (#1797). @@ -472,7 +472,7 @@ extension RunnerTests { 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(rect: viewport)), options: PresentationOptions(interactiveOnly: true, depth: nil, scope: nil, raw: false), policy: .cursorProjected ) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+PrivateAXPresentationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+PrivateAXPresentationTests.swift index 23ee5db993..f527d4518c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+PrivateAXPresentationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+PrivateAXPresentationTests.swift @@ -42,7 +42,8 @@ extension RunnerTests { interfaceOrientation: RunnerInterfaceOrientation.portrait) return try SnapshotPresentation.presentRegular( SnapshotAcquisition( - hint: hint, nodes: nodes, truncated: false, effectiveDepth: nil, viewport: viewport, + hint: hint, nodes: nodes, truncated: false, effectiveDepth: nil, + viewport: .reported(rect: viewport), interfaceOrientation: RunnerInterfaceOrientation.portrait), options: PresentationOptions( interactiveOnly: interactiveOnly, depth: nil, scope: nil, raw: false), @@ -59,7 +60,7 @@ extension RunnerTests { ) -> [RawAXNode] { SnapshotGeometrySpace.normalized( nodes: privateAXAcquisition(rawRoot: rawRoot, hint: hint), - viewport: viewport, + viewport: .reported(rect: viewport), interfaceOrientation: interfaceOrientation ) } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift index a2c709eeb9..3a508bb506 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift @@ -282,7 +282,7 @@ extension RunnerTests { nodes: [], truncated: false, effectiveDepth: nil, - viewport: .infinite + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 402, height: 874)) ), options: options ) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotHittabilityTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotHittabilityTests.swift index 61e0024121..4349fc6530 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotHittabilityTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotHittabilityTests.swift @@ -97,7 +97,7 @@ extension RunnerTests { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: CGRect(x: 0, y: 0, width: 100, height: 100) + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) ), options: options ) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationConformanceTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationConformanceTests.swift index 718eb5651f..aa82a8c173 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationConformanceTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationConformanceTests.swift @@ -97,7 +97,7 @@ extension RunnerTests { }, truncated: false, effectiveDepth: nil, - viewport: fixture.viewport.cgRect + viewport: .reported(rect: fixture.viewport.cgRect) ) let options = PresentationOptions( interactiveOnly: false, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationGeometryTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationGeometryTests.swift index 087f00e510..1e15593a2b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationGeometryTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationGeometryTests.swift @@ -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(rect: CGRect(x: 0, y: 0, width: 402, height: 874)), ancestorClip: CGRect(x: 300, y: 100, width: 80, height: 80) ) @@ -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(rect: CGRect(x: 0, y: 0, width: 402, height: 874)), ancestorClip: nil ) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationInvariantTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationInvariantTests.swift index c0c739b86d..241d687664 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationInvariantTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationInvariantTests.swift @@ -97,7 +97,7 @@ extension RunnerTests { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: viewport + viewport: .reported(rect: viewport) ) } @@ -147,7 +147,7 @@ extension RunnerTests { ], truncated: false, effectiveDepth: nil, - viewport: CGRect(x: 0, y: 0, width: 320, height: 240) + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 320, height: 240)) ) let options = PresentationOptions( @@ -203,7 +203,7 @@ extension RunnerTests { ], truncated: false, effectiveDepth: nil, - viewport: CGRect(x: 0, y: 0, width: 320, height: 240) + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 320, height: 240)) ) let options = PresentationOptions( @@ -254,7 +254,7 @@ extension RunnerTests { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: .infinite + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) ), options: options ).nodes) @@ -309,7 +309,7 @@ extension RunnerTests { XCTAssertThrowsError( try SnapshotPresentationInvariant.validateRegular( folded, - viewport: viewport, + viewport: .reported(rect: viewport), policy: .cursorProjected ) ) { error in diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift index c103678186..bbb4a3a8a6 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift @@ -40,7 +40,7 @@ extension RunnerTests { truncated: 0, blocked: false ), - viewport: .infinite + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) ), options: PresentationOptions( interactiveOnly: true, @@ -113,7 +113,7 @@ extension RunnerTests { projection: .regular, depth: nil, regularPresentedDepth: nil, interactiveOnly: false, customActions: false), nodes: acquired, truncated: false, effectiveDepth: nil, - viewport: CGRect(x: 0, y: 0, width: 1_000, height: 1_000)), + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 1_000, height: 1_000))), options: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false) ).nodes ) @@ -124,7 +124,7 @@ extension RunnerTests { projection: .regular, depth: nil, regularPresentedDepth: nil, interactiveOnly: true, customActions: false), nodes: acquired, truncated: false, effectiveDepth: nil, - viewport: CGRect(x: 0, y: 0, width: 1_000, height: 1_000)), + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 1_000, height: 1_000))), options: PresentationOptions(interactiveOnly: true, depth: nil, scope: nil, raw: false) ).nodes ) @@ -147,7 +147,8 @@ extension RunnerTests { hint: CaptureHint( projection: .raw, depth: nil, regularPresentedDepth: nil, interactiveOnly: false, customActions: false), - nodes: acquired, truncated: false, effectiveDepth: nil, viewport: .infinite), + nodes: acquired, truncated: false, effectiveDepth: nil, + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 1_000, height: 1_000)) ), options: PresentationOptions(interactiveOnly: true, depth: nil, scope: nil, raw: true) ).nodes ) @@ -176,7 +177,7 @@ extension RunnerTests { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: CGRect(x: 0, y: 0, width: 100, height: 100) + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) ) let presented = try SnapshotPresentation.presentRegular( @@ -211,7 +212,7 @@ extension RunnerTests { nodes: acquired, truncated: false, effectiveDepth: nil, - viewport: viewport + viewport: .reported(rect: viewport) ), options: regularOptions ).nodes @@ -231,7 +232,7 @@ extension RunnerTests { nodes: acquired, truncated: false, effectiveDepth: nil, - viewport: .infinite + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) ), options: rawOptions ).nodes @@ -286,7 +287,7 @@ extension RunnerTests { ], truncated: false, effectiveDepth: nil, - viewport: CGRect(x: 0, y: 0, width: 1_000, height: 1_000) + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 1_000, height: 1_000)) ) let options = PresentationOptions( interactiveOnly: true, @@ -313,7 +314,7 @@ extension RunnerTests { nodes: acquisition.nodes, truncated: false, effectiveDepth: nil, - viewport: .infinite + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) ), options: PresentationOptions( interactiveOnly: true, @@ -374,10 +375,11 @@ extension RunnerTests { let regularAcquisition = SnapshotAcquisition( hint: SnapshotPresentation.captureHint(for: regularRequest), nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: CGRect(x: 0, y: 0, width: 100, height: 100)) + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100))) let rawAcquisition = SnapshotAcquisition( hint: SnapshotPresentation.captureHint(for: rawRequest), - nodes: nodes, truncated: false, effectiveDepth: nil, viewport: .infinite) + nodes: nodes, truncated: false, effectiveDepth: nil, + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100))) let regularCaptureForRawRequest = try SnapshotPresentation.present( regularAcquisition, options: rawRequest) @@ -470,7 +472,7 @@ extension RunnerTests { ], truncated: false, effectiveDepth: nil, - viewport: CGRect(x: 0, y: 0, width: 100, height: 100) + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) ), options: options ) @@ -552,7 +554,7 @@ extension RunnerTests { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: viewport + viewport: .reported(rect: viewport) ) let presented = try XCTUnwrap( SnapshotPresentation.present(acquisition, options: options)?.nodes) @@ -596,7 +598,7 @@ extension RunnerTests { ] let normalized = SnapshotGeometrySpace.normalized( nodes: acquired, - viewport: viewport, + viewport: .reported(rect: viewport), interfaceOrientation: RunnerInterfaceOrientation.landscapeRight ) let options = PresentationOptions(interactiveOnly: false, depth: 3, scope: nil, raw: false) @@ -608,7 +610,7 @@ extension RunnerTests { nodes: normalized, truncated: false, effectiveDepth: nil, - viewport: viewport + viewport: .reported(rect: viewport) ), options: options )?.nodes diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift index 2063c56f14..432ecf95f1 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift @@ -25,7 +25,11 @@ extension RunnerTests { policy: SnapshotVisibilityFold.Policy = .cursorProjected ) -> [SnapshotPresentationNode] { SnapshotVisibilityFold.fold( - nodes, viewport: viewport, interactiveOnly: interactiveOnly, policy: policy) + nodes, + viewport: .reported(rect: viewport), + interactiveOnly: interactiveOnly, + policy: policy + ) } func testRegularFoldClipsScrollOverflowReparentsAndBooksHints() { diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift index 561861bc29..85c89b519b 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift @@ -164,10 +164,14 @@ public enum SnapshotGeometrySpace: Equatable { extension SnapshotGeometrySpace { public static func normalized( nodes: [RawAXNode], - viewport: CGRect, + viewport: SnapshotViewport, interfaceOrientation: Int ) -> [RawAXNode] { let carriers = SnapshotVisibilityFold.visibilityExemptCarrierTypes + // No viewport box means no app frame to be quarter-turned relative to either. `.null` is the box + // `isPlottable` refuses, so this pass turns nothing — the outcome `CGRect.infinite` produced + // before the fact carried the absence. + let appFrame = viewport.rect ?? .null var spaces = [SnapshotGeometrySpace](repeating: .appOrientation, count: nodes.count) var result: [RawAXNode] = [] result.reserveCapacity(nodes.count) @@ -180,7 +184,7 @@ extension SnapshotGeometrySpace { ), reportedFrame: node.rect.cgRect, inheritedFrom: parentIndex.map { spaces[$0] } ?? .appOrientation, - appFrame: viewport, + appFrame: appFrame, interfaceOrientation: interfaceOrientation ) spaces[position] = nodeSpace diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift index ec8bc50863..6e6dfa59e3 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift @@ -2,14 +2,34 @@ import Foundation import CoreGraphics public enum SnapshotGeometry { + /// The rect precondition of the shared `hittable` predicate and of anything that may serve as a + /// viewport box, mirroring `isPositiveFiniteRect` in `packages/kernel/src/rect.ts`. Swift previously + /// checked null/empty only, so two boxes the TypeScript side refuses were actionable here: a + /// non-canonical box — a negative width, which the private AX bridge's JSON frame parser can hand + /// over — and `CGRect.infinite`, whose center is (0,0) and therefore lands inside any viewport + /// (#2891). `isInfinite` is checked apart from the components because `CGRect.infinite` is made of + /// finite `Double`s (±DBL_MAX/2 and DBL_MAX); a box with actual infinite components is refused by + /// the component checks instead. + public static func isPositiveFinite(_ rect: CGRect) -> Bool { + !rect.isInfinite + && rect.origin.x.isFinite && rect.origin.y.isFinite + && rect.size.width.isFinite && rect.size.height.isFinite + && rect.size.width > 0 && rect.size.height > 0 + } + + /// Clipping asks one question and containment asks another, so they read the fact separately. Here: + /// is there a box to clip against at all? A capture with no viewport cannot clip anything, which is + /// not the same claim as "no node is inside it" — that second one is + /// `isGeometricallyActionable`, and `SnapshotPresentationInvariant` relies on this pair by taking + /// the same `rect` (no box means no cumulative clip to violate, not an unbounded clip). public static func effectiveFrame( reportedFrame: CGRect, - viewport: CGRect, + viewport: SnapshotViewport, ancestorClip: CGRect? ) -> CGRect { var frame = reportedFrame - if !viewport.isInfinite { - frame = clipped(frame, to: viewport) + if let box = viewport.rect { + frame = clipped(frame, to: box) } if let ancestorClip { frame = clipped(frame, to: ancestorClip) @@ -34,15 +54,36 @@ public enum SnapshotGeometry { ) } + /// The one `hittable` predicate every iOS snapshot producer publishes (#1933), twin of + /// `isGeometricallyActionable` in `packages/kernel/src/rect.ts`, including `CGRect.contains`'s + /// half-open right and bottom edges: a center landing exactly on the viewport's right or bottom + /// edge is not hittable on either producer. Both languages first refuse a node rect that is not + /// positive and finite, so the precondition is one rule and not two (#2891). + /// + /// ## The unknown viewport — this site fails CLOSED + /// + /// `hittable` claims that a tap at the node's center lands. A capture with no viewport box cannot + /// support that claim, so `.missing` publishes no actionability at all. That is the host's own + /// direction with the instrument each side has: `resolveViewportEvidence` in + /// `packages/capture-kit/src/ios-snapshot-engine/invariants.ts` refuses to fold a regular + /// presentation without a positive finite viewport, so the TypeScript predicate is never reached + /// with an unknown viewport — its `viewport` parameter is total by construction, which is why it + /// has no case for the state. The runner's tree still presents: an empty interactive result is + /// visible to the caller and the plan can still reach a tier that resolves a box, while an + /// unsupported `true` is silent and would send a tap to a point nothing has located. + /// + /// A `.derived` box keeps answering containment. It is a screen box the capture read from its own + /// root element, and `hittable` is load-bearing downstream (the #2638 wrapper verdict reads a + /// declared `false` as evidence the wrapper is inert), so the fail-closed case stays exactly the + /// one case with no box. public static func isGeometricallyActionable( enabled: Bool, frame: CGRect, - viewport: CGRect + viewport: SnapshotViewport ) -> Bool { - guard enabled, !frame.isNull, !frame.isEmpty else { return false } - if viewport.isInfinite { return true } - let center = CGPoint(x: frame.midX, y: frame.midY) - return viewport.contains(center) + guard enabled, isPositiveFinite(frame) else { return false } + guard let box = viewport.rect else { return false } + return box.contains(CGPoint(x: frame.midX, y: frame.midY)) } private static func clipped(_ frame: CGRect, to clip: CGRect) -> CGRect { diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift index 95a3cf39d7..9c22cd6f35 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift @@ -153,13 +153,59 @@ public struct PresentationOptions: Equatable { } } +/// What a capture knows about the viewport hosting its tree, as the three-case fact the host's +/// `IosViewportEvidence` already uses (#2891). A rectangle is never allowed to stand for "unknown": +/// `CGRect.infinite` crossing this boundary read as "everything is actionable" on the runner and as +/// "publish nothing" on the host, which is the same state resolved in two directions. +public enum SnapshotViewport: Equatable { + /// The platform's own box for the app's surface. + case reported(rect: CGRect) + /// A box the capture inferred for itself out of its own root element instead of a screen read. It + /// clips and contains like a reported box, and it never anchors a rotation: the tier that produces + /// it reports no interface orientation beside it (#2612). + case derived(rect: CGRect) + /// No box. See `SnapshotGeometry.isGeometricallyActionable` for the one policy this answers. + case missing(reason: MissingReason) + + public enum MissingReason: Equatable { + /// Nothing was read: the read was skipped, or it raised. + case notProvided + /// A box arrived that cannot be a viewport — null, empty, or non-finite. + case invalid + } + + /// The box to compare geometry against, or `nil` when the capture has none. Nothing that needs a + /// box may substitute an unbounded one for the absence of one. + public var rect: CGRect? { + switch self { + case .reported(let rect), .derived(let rect): + return rect + case .missing: + return nil + } + } + + /// Declares the box the platform reported for the app's surface. A box that cannot be a viewport + /// becomes `.missing(reason: .invalid)` here, at the one place a box becomes a viewport, so no + /// consumer has to re-check what it was handed. + public static func reported(box: CGRect) -> SnapshotViewport { + SnapshotGeometry.isPositiveFinite(box) ? .reported(rect: box) : .missing(reason: .invalid) + } + + /// Declares the capture's own root box as its viewport. Same refusal as `reported(box:)`: an + /// unusable root box is no box at all. + public static func derived(box: CGRect) -> SnapshotViewport { + SnapshotGeometry.isPositiveFinite(box) ? .derived(rect: box) : .missing(reason: .invalid) + } +} + public struct SnapshotAcquisition { public let hint: CaptureHint public var nodes: [RawAXNode] public let truncated: Bool public let effectiveDepth: Int? public var customActions: SnapshotCustomActionCoverage? - public let viewport: CGRect + public let viewport: SnapshotViewport /// The app's interface orientation, consumed by the one `normalized` pass; `unknown` turns nothing. public let interfaceOrientation: Int @@ -169,7 +215,7 @@ public struct SnapshotAcquisition { truncated: Bool, effectiveDepth: Int?, customActions: SnapshotCustomActionCoverage? = nil, - viewport: CGRect, + viewport: SnapshotViewport, interfaceOrientation: Int = 0 ) { self.hint = hint diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationInvariant.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationInvariant.swift index 7ce2f25589..5390d79c09 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationInvariant.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationInvariant.swift @@ -1,6 +1,14 @@ import Foundation import CoreGraphics +/// What `SnapshotPresentation` guarantees about the regular projection it just folded. +/// +/// The viewport enters exactly once, as the root of the cumulative ancestor clip. When the capture +/// has no viewport box the clip has no root, so containment has nothing to violate — that is the +/// absence of an answer, not an unbounded clip, and it is why `SnapshotViewport.rect` is `nil` here +/// rather than a box that swallows every check. The degenerate-node rule below does not consult the +/// viewport at all, so a capture that cannot locate the screen still cannot present a null or empty +/// frame as actionable (#2638 reads that verdict as evidence a wrapper is inert). public enum SnapshotPresentationInvariant { struct ValidationStats: Equatable { let parentClipLookups: Int @@ -12,7 +20,7 @@ public enum SnapshotPresentationInvariant { public static func validateRegular( _ nodes: [SnapshotPresentationNode], - viewport: CGRect, + viewport: SnapshotViewport, policy: SnapshotVisibilityFold.Policy ) throws { _ = try validateRegularWithStats(nodes, viewport: viewport, policy: policy) @@ -20,24 +28,25 @@ public enum SnapshotPresentationInvariant { static func validateRegularWithStats( _ nodes: [SnapshotPresentationNode], - viewport: CGRect, + viewport: SnapshotViewport, policy: SnapshotVisibilityFold.Policy ) throws -> ValidationStats { var parentClipLookups = 0 - var clipIncludingNodeByIndex: [Int: CGRect] = [:] + var clipIncludingNodeByIndex: [Int: CGRect?] = [:] clipIncludingNodeByIndex.reserveCapacity(nodes.count) + let rootClip = viewport.rect for node in nodes { - let ancestorClip: CGRect + let ancestorClip: CGRect? if let parentIndex = node.raw.parentIndex { parentClipLookups += 1 - ancestorClip = clipIncludingNodeByIndex[parentIndex] ?? viewport + ancestorClip = clipIncludingNodeByIndex[parentIndex] ?? rootClip } else { - ancestorClip = viewport + ancestorClip = rootClip } let frame = node.effectiveRect.cgRect - let clipIncludingNode: CGRect + let clipIncludingNode: CGRect? if policy == .cursorProjected, SnapshotVisibilityFold.scrollContainerTypeNames.contains(node.raw.type), !frame.isNull, @@ -59,11 +68,11 @@ public enum SnapshotPresentationInvariant { continue } - guard contains(frame, in: ancestorClip) else { + if let clip = ancestorClip, !contains(frame, in: clip) { throw SnapshotPresentationFailure.regularNodeOutsideCumulativeClip( index: node.raw.index, frame: node.effectiveRect, - clip: SnapshotRect(ancestorClip) + clip: SnapshotRect(clip) ) } } diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift index afd0b479c4..d304ca55a6 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift @@ -100,7 +100,7 @@ public enum SnapshotVisibilityFold { public static func traversalDecision( for node: RawAXNode, parent: TraversalState, - viewport: CGRect, + viewport: SnapshotViewport, interactiveOnly: Bool, hasChildren: Bool, policy: Policy @@ -153,7 +153,7 @@ public enum SnapshotVisibilityFold { public static func fold( _ nodes: [RawAXNode], - viewport: CGRect, + viewport: SnapshotViewport, interactiveOnly: Bool, policy: Policy ) -> [SnapshotPresentationNode] { diff --git a/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift b/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift index 89bd98c4a1..33e281c9bf 100644 --- a/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift +++ b/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift @@ -75,7 +75,11 @@ private func acquisition(for input: ConformanceInput) -> SnapshotAcquisition { }, truncated: false, effectiveDepth: nil, - viewport: input.viewport.cgRect + // The differential always reports a viewport. Without one the host engine's regular presentation + // throws `missing-viewport` before folding (`invariants.ts`), so an unknown viewport has no + // TypeScript outcome to compare a fold against; the predicate's unknown-viewport rows belong to + // contracts/fixtures/snapshot-actionability-policy.json instead (#2891). + viewport: .reported(rect: input.viewport.cgRect) ) } diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ConformanceTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ConformanceTests.swift index 2e526095bf..12caa23669 100644 --- a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ConformanceTests.swift +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ConformanceTests.swift @@ -49,7 +49,7 @@ final class ConformanceTests: XCTestCase { ], truncated: false, effectiveDepth: nil, - viewport: CGRect(x: 0, y: 0, width: 100, height: 100) + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) ), options: options, policy: .cursorProjected diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/CoordinateSpaceTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/CoordinateSpaceTests.swift index 90e67e938d..be95c60eb1 100644 --- a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/CoordinateSpaceTests.swift +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/CoordinateSpaceTests.swift @@ -253,13 +253,14 @@ final class CoordinateSpaceTests: XCTestCase { ), .appOrientation ) - // An app frame the capture could not resolve cannot anchor a rotation. + // An app frame the capture could not resolve cannot anchor a rotation. `.null` is what the one + // normalization pass hands over for a capture whose viewport fact is `missing` (#2891). XCTAssertEqual( SnapshotGeometrySpace.space( reportedBySurfaceHost: true, reportedFrame: rotated, inheritedFrom: .appOrientation, - appFrame: .infinite, + appFrame: .null, interfaceOrientation: RunnerInterfaceOrientation.landscapeRight ), .appOrientation @@ -421,7 +422,7 @@ final class CoordinateSpaceTests: XCTestCase { let expected = (namesQuarterTurn && !squareApp) ? testCase.oriented : testCase.native let normalized = SnapshotGeometrySpace.normalized( nodes: turnedSubtree(app: app, reportedLeaf: testCase.native.cgRect), - viewport: app, + viewport: .reported(rect: app), interfaceOrientation: testCase.interfaceOrientation ) XCTAssertEqual(normalized.count, 4) @@ -443,7 +444,7 @@ final class CoordinateSpaceTests: XCTestCase { ] let normalized = SnapshotGeometrySpace.normalized( nodes: acquired, - viewport: app, + viewport: .reported(rect: app), interfaceOrientation: RunnerInterfaceOrientation.landscapeRight ) XCTAssertEqual( @@ -470,7 +471,7 @@ final class CoordinateSpaceTests: XCTestCase { ] let normalized = SnapshotGeometrySpace.normalized( nodes: acquired, - viewport: app, + viewport: .reported(rect: app), interfaceOrientation: RunnerInterfaceOrientation.unknown ) XCTAssertEqual(normalized.map(\.rect), acquired.map(\.rect)) @@ -483,7 +484,7 @@ final class CoordinateSpaceTests: XCTestCase { XCTAssertEqual( SnapshotGeometrySpace.normalized( nodes: [], - viewport: CGRect(x: 0, y: 0, width: 874, height: 402), + viewport: .reported(rect: CGRect(x: 0, y: 0, width: 874, height: 402)), interfaceOrientation: RunnerInterfaceOrientation.landscapeRight ), [] diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/InvariantTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/InvariantTests.swift index 6b06f8c5a4..b10d8c259f 100644 --- a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/InvariantTests.swift +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/InvariantTests.swift @@ -28,7 +28,7 @@ final class InvariantTests: XCTestCase { let stats = try SnapshotPresentationInvariant.validateRegularWithStats( nodes, - viewport: viewport, + viewport: .reported(rect: viewport), policy: .cursorProjected ) diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/RegularDepthTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/RegularDepthTests.swift index 6d7bc3f529..912fcfca04 100644 --- a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/RegularDepthTests.swift +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/RegularDepthTests.swift @@ -21,7 +21,7 @@ final class RegularDepthTests: XCTestCase { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: viewport + viewport: .reported(rect: viewport) ) let result = try XCTUnwrap(SnapshotPresentation.present(acquisition, options: options)) diff --git a/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index dc46f6db6c..111cccc20d 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -304,6 +304,19 @@ a typed `IOS_SNAPSHOT_PRESENTATION_FAILED` capture failure with the named `prese snapshot-quality reason, preserved through recovery and the existing TypeScript verdict/warning contract. +Inside the runner the viewport is a declared fact and not a rectangle: `SnapshotViewport` carries +`reported`, `derived`, or `missing { reason }`, the three cases `IosViewportEvidence` already uses on +the host. The runner previously spelled "unknown" as `CGRect.infinite`, and that one state resolved in +opposite directions — every node actionable on the runner, none on the host (#2891). One policy, +stated at the Swift decision site, fails CLOSED: with no viewport box no node is actionable, while the +clip skips and the cumulative-clip invariant is left with no root clip to violate rather than an +unbounded one. The host reaches the same direction earlier and harder, because its engine's +`resolveViewportEvidence` refuses to fold a regular presentation at all — which is why the TypeScript +predicate has no unknown-viewport case and why this state cannot be compared through the fold +differential. `contracts/fixtures/snapshot-actionability-policy.json` pins the predicate for the shapes +the fixed 320x240 fold fixture cannot reach: the half-open right and bottom edges, a node rect neither +language's null/empty check refuses, and an unknown viewport. + A regular `--depth` request is a presentation cut, not an acquisition bound. `CaptureHint` keeps raw traversal depth (`--raw --depth`) separate from regular presented depth, but the recursive tree walk no longer reads presented depth (or any geometry) while descending: for a regular capture it From 28f7cb849b327b78219bf922d019a4078ec5754f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 14:49:39 +0200 Subject: [PATCH 2/7] test(ios): pin the hittable predicate with a cross-language vector table The fixed 320x240 fold fixture cannot reach an edge-of-viewport center, a node rect neither language's null/empty check refuses, or an unknown viewport, so the predicate's two implementations disagreed on two of them: CGRect.infinite is built of finite Doubles and its center is (0,0), and a negative width passes an empty check while its center lands inside -- both actionable in Swift and not actionable in TypeScript. The private AX bridge's JSON frame parser can hand over either. contracts/fixtures/snapshot-actionability-policy.json is replayed by ActionabilityPolicyTests (Swift) and by scripts/ios-snapshot-differential .test.ts (TypeScript), which also asserts the host's own instrument: resolveViewportEvidence refuses a regular presentation without a positive finite viewport, which is why the TypeScript predicate has no unknown viewport case. Rows come in pairs sharing one center so a `false` that the guard, rather than containment, decides cannot go quiet. --- .../ActionabilityPolicyTests.swift | 160 ++++++++ .../snapshot-actionability-policy.json | 370 ++++++++++++++++++ packages/kernel/src/rect.ts | 21 +- scripts/ios-snapshot-differential.test.ts | 105 +++++ 4 files changed, 650 insertions(+), 6 deletions(-) create mode 100644 apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift create mode 100644 contracts/fixtures/snapshot-actionability-policy.json diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift new file mode 100644 index 0000000000..1d991de95f --- /dev/null +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift @@ -0,0 +1,160 @@ +import AgentDeviceSnapshotPresentation +import CoreGraphics +import Foundation +import XCTest + +/// Golden vector table for the shared `hittable` predicate (#2891). The same +/// `contracts/fixtures/snapshot-actionability-policy.json` rows are replayed against the TypeScript +/// twin (`isGeometricallyActionable` in `packages/kernel/src/rect.ts`) by +/// `scripts/ios-snapshot-differential.test.ts`, so drift between the runner's Swift rule and the +/// host's reads red on whichever side moved. +final class ActionabilityPolicyTests: XCTestCase { + private struct Table: Decodable { + let description: String + let cases: [PolicyCase] + } + + /// A rect as JSON can carry one. Infinity has no JSON spelling, so the unusable box a platform + /// hands back is named `{"infinite": true}` (`window-coordinate-space.json` spells it the same). + private struct RectBox: Decodable { + private enum CodingKeys: String, CodingKey { + case x, y, width, height, infinite + } + + let cgRect: CGRect + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard try container.decodeIfPresent(Bool.self, forKey: .infinite) != true else { + self.cgRect = .infinite + return + } + self.cgRect = CGRect( + x: try container.decode(Double.self, forKey: .x), + y: try container.decode(Double.self, forKey: .y), + width: try container.decode(Double.self, forKey: .width), + height: try container.decode(Double.self, forKey: .height) + ) + } + } + + /// The viewport as the three-case fact it crosses the boundary as. A row declaring `reported` or + /// `derived` has to survive the declaration factories with that kind intact, so the table cannot + /// quietly start exercising the `missing` policy under a reported label. + private struct ViewportFact: Decodable { + private enum CodingKeys: String, CodingKey { + case kind, rect, reason + } + + let declaredKind: String + let viewport: SnapshotViewport + + /// A row labelled `reported` whose box cannot be a viewport would silently start testing the + /// `missing` policy, so the declaration has to come back with the kind the row names. + var matchesDeclaredKind: Bool { + switch (declaredKind, viewport) { + case ("reported", .reported), ("derived", .derived), ("missing", .missing): + return true + default: + return false + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + self.declaredKind = kind + switch kind { + case "reported": + self.viewport = .reported(box: try container.decode(RectBox.self, forKey: .rect).cgRect) + case "derived": + self.viewport = .derived(box: try container.decode(RectBox.self, forKey: .rect).cgRect) + case "missing": + switch try container.decode(String.self, forKey: .reason) { + case "not-provided": + self.viewport = .missing(reason: .notProvided) + case "invalid": + self.viewport = .missing(reason: .invalid) + default: + throw DecodingError.dataCorruptedError( + forKey: .reason, + in: container, + debugDescription: "unknown missing-viewport reason" + ) + } + default: + throw DecodingError.dataCorruptedError( + forKey: .kind, + in: container, + debugDescription: "unknown viewport kind" + ) + } + } + } + + private struct PolicyCase: Decodable { + let name: String + let enabled: Bool + let node: RectBox + let viewport: ViewportFact + let hittable: Bool + let nodeRectGuardPasses: Bool + } + + func testActionabilityPolicyAgreesWithEveryGoldenVector() throws { + let table = try loadActionabilityPolicyTable() + XCTAssertFalse(table.cases.isEmpty, "vector table must not be empty") + XCTAssertEqual( + Set(table.cases.map(\.name)).count, + table.cases.count, + "vector names must be unique" + ) + for testCase in table.cases { + XCTAssertTrue( + testCase.viewport.matchesDeclaredKind, + "\(testCase.name): declared \(testCase.viewport.declaredKind) must survive declaration" + ) + XCTAssertEqual( + SnapshotGeometry.isPositiveFinite(testCase.node.cgRect), + testCase.nodeRectGuardPasses, + "\(testCase.name): node-rect guard" + ) + XCTAssertEqual( + SnapshotGeometry.isGeometricallyActionable( + enabled: testCase.enabled, + frame: testCase.node.cgRect, + viewport: testCase.viewport.viewport + ), + testCase.hittable, + testCase.name + ) + } + } + + /// The table cannot be trimmed until the unknown-viewport policy is the only thing left untested: + /// every declared kind has to be present, and no `missing` row may rest on a node rect that the + /// guard already refuses — that would make the row's `false` say nothing about the policy. + func testActionabilityPolicyCoversEveryViewportKindWithoutAVacuousMissingRow() throws { + let cases = try loadActionabilityPolicyTable().cases + XCTAssertEqual(Set(cases.map(\.viewport.declaredKind)), ["reported", "derived", "missing"]) + for testCase in cases where testCase.viewport.declaredKind == "missing" { + XCTAssertTrue( + testCase.nodeRectGuardPasses, + "\(testCase.name): a missing-viewport row must have a node the guard accepts" + ) + } + } + + private func loadActionabilityPolicyTable() throws -> Table { + let tableURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // AgentDeviceSnapshotPresentationTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // snapshot-presentation + .deletingLastPathComponent() // apple + .deletingLastPathComponent() // repo root + .appendingPathComponent("contracts") + .appendingPathComponent("fixtures") + .appendingPathComponent("snapshot-actionability-policy.json") + return try JSONDecoder().decode(Table.self, from: Data(contentsOf: tableURL)) + } +} diff --git a/contracts/fixtures/snapshot-actionability-policy.json b/contracts/fixtures/snapshot-actionability-policy.json new file mode 100644 index 0000000000..117eac3054 --- /dev/null +++ b/contracts/fixtures/snapshot-actionability-policy.json @@ -0,0 +1,370 @@ +{ + "description": "Golden vector table for the shared `hittable` predicate (#1933), pinned for the input shapes the fixed 320x240 fold fixture in ios-snapshot-engine-conformance.json cannot reach (#2891). RULE: a node is actionable when it is enabled, its own rect is positive and finite, and its center falls inside the viewport box, half-open on the right and bottom edges (inclusive on the left and top). Two implementations: isGeometricallyActionable in apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift (replayed by ActionabilityPolicyTests, and reached on device through SnapshotGeometrySpace.normalized and SnapshotVisibilityFold.fold) and isGeometricallyActionable in packages/kernel/src/rect.ts (replayed by scripts/ios-snapshot-differential.test.ts). THE VIEWPORT IS A DECLARED FACT, not a rectangle: `reported` is the platform's box for the app's surface, `derived` is a box the capture inferred for itself out of its own root element, and `missing` carries `not-provided` (nothing was read) or `invalid` (a box arrived that cannot be one). These are the three cases of IosViewportEvidence in packages/contracts/src/ios-snapshot.ts, and the old spelling \u2014 CGRect.infinite as the viewport \u2014 made every node actionable on the runner while the host published nothing, which is one state resolved in two directions. POLICY FOR AN UNKNOWN VIEWPORT, stated once at the Swift decision site and failing CLOSED: `missing` publishes no actionability, because `hittable` claims a tap at the center lands and a capture with no box cannot support that; `hittable: false` is visible to the caller as an empty interactive result, an unsupported `true` is silent. The host reaches the same direction with a stronger instrument: resolveViewportEvidence in packages/capture-kit/src/ios-snapshot-engine/invariants.ts throws `missing-viewport`/`invalid-viewport` and refuses to fold a regular presentation at all, so the TypeScript predicate has no case for the state \u2014 its viewport parameter is total by construction. That is why `missing` rows are asserted differently per language: Swift asserts the predicate answers false, TypeScript asserts the capture-level refusal fires and the node-rect guard claim holds. `derived` keeps answering containment on both sides: it is a screen box the capture read out of its own root element (the runner's private AX tier falls back to it, and a host provider adapter reaches the same shape from an element tree), and the host's decline for that kind is a source-stage decision about who may claim the bit \u2014 `publishDerivedHittability` in packages/platform-apple/src/snapshot-source/tree.ts runs only for a `reported` viewport \u2014 not a second answer from the predicate. Widening the fail-closed case to `derived` was rejected because `hittable` is load-bearing downstream: the #2638 wrapper verdict reads a declared false as evidence a wrapper is inert, so the case that stops claiming actionability stays exactly the case with no box at all. WHY SOME ROWS COME IN PAIRS: several rows state an expectation both sides already agreed on before #2891, so a bare false would be vacuous. Each such row has a sibling reaching the SAME center through a box that passes the guard and answering true, which shows the guard, not containment, is what decides. Two of them were genuine divergences: `CGRect.infinite` is built of finite Doubles (\u00b1DBL_MAX/2, DBL_MAX) so its center is (0,0) and the Swift side called it actionable inside any viewport, and a negative width passes a null/empty check while its center lands inside, which the private AX bridge's JSON frame parser can hand over. Infinity has no JSON spelling, so the unusable box is named {\"infinite\": true} and decodes to CGRect.infinite on the Swift side and to an infinite rect on the TypeScript side, as in window-coordinate-space.json. Each row also carries `nodeRectGuardPasses`: whether `node` satisfies that positive-finite precondition, asserted in both languages so a drift in either guard names itself.", + "cases": [ + { + "name": "a center strictly inside the reported box is actionable", + "enabled": true, + "node": { + "x": 100, + "y": 100, + "width": 40, + "height": 20 + }, + "viewport": { + "kind": "reported", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": true, + "nodeRectGuardPasses": true + }, + { + "name": "a center one point short of the right edge is actionable", + "enabled": true, + "node": { + "x": 239, + "y": 110, + "width": 160, + "height": 20 + }, + "viewport": { + "kind": "reported", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": true, + "nodeRectGuardPasses": true + }, + { + "name": "a center landing exactly on the right edge is not actionable: the edge is half-open", + "enabled": true, + "node": { + "x": 240, + "y": 110, + "width": 160, + "height": 20 + }, + "viewport": { + "kind": "reported", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": false, + "nodeRectGuardPasses": true + }, + { + "name": "a center one point short of the bottom edge is actionable", + "enabled": true, + "node": { + "x": 100, + "y": 159, + "width": 40, + "height": 160 + }, + "viewport": { + "kind": "reported", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": true, + "nodeRectGuardPasses": true + }, + { + "name": "a center landing exactly on the bottom edge is not actionable: the edge is half-open", + "enabled": true, + "node": { + "x": 100, + "y": 160, + "width": 40, + "height": 160 + }, + "viewport": { + "kind": "reported", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": false, + "nodeRectGuardPasses": true + }, + { + "name": "a center landing exactly on the left edge is actionable: that edge is inclusive", + "enabled": true, + "node": { + "x": -20, + "y": 100, + "width": 40, + "height": 20 + }, + "viewport": { + "kind": "reported", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": true, + "nodeRectGuardPasses": true + }, + { + "name": "a center landing exactly on the top edge is actionable: that edge is inclusive", + "enabled": true, + "node": { + "x": 100, + "y": -10, + "width": 40, + "height": 20 + }, + "viewport": { + "kind": "reported", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": true, + "nodeRectGuardPasses": true + }, + { + "name": "a disabled node is not actionable even with its center inside the box", + "enabled": false, + "node": { + "x": 100, + "y": 100, + "width": 40, + "height": 20 + }, + "viewport": { + "kind": "reported", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": false, + "nodeRectGuardPasses": true + }, + { + "name": "a center inside the box reached through a canonical box is actionable", + "enabled": true, + "node": { + "x": 80, + "y": 100, + "width": 20, + "height": 20 + }, + "viewport": { + "kind": "reported", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": true, + "nodeRectGuardPasses": true + }, + { + "name": "the same center reached through a negative width is not actionable: the node guard decides, and Swift used to answer actionable", + "enabled": true, + "node": { + "x": 100, + "y": 100, + "width": -20, + "height": 20 + }, + "viewport": { + "kind": "reported", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": false, + "nodeRectGuardPasses": false + }, + { + "name": "a center inside the box reached through a zero width is not actionable", + "enabled": true, + "node": { + "x": 100, + "y": 110, + "width": 0, + "height": 20 + }, + "viewport": { + "kind": "reported", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": false, + "nodeRectGuardPasses": false + }, + { + "name": "that zero-area box's center reached through a canonical box is actionable", + "enabled": true, + "node": { + "x": 90, + "y": 110, + "width": 20, + "height": 20 + }, + "viewport": { + "kind": "reported", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": true, + "nodeRectGuardPasses": true + }, + { + "name": "a center inside the box reached through a box spelled infinite is not actionable, and Swift used to answer actionable because its center is (0,0)", + "enabled": true, + "node": { + "infinite": true + }, + "viewport": { + "kind": "reported", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": false, + "nodeRectGuardPasses": false + }, + { + "name": "the center a box spelled infinite lands on is actionable when a real box reaches it", + "enabled": true, + "node": { + "x": -10, + "y": -10, + "width": 20, + "height": 20 + }, + "viewport": { + "kind": "reported", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": true, + "nodeRectGuardPasses": true + }, + { + "name": "the same node whose center sits inside a reported box is not actionable once the viewport goes missing: the policy fails closed", + "enabled": true, + "node": { + "x": 100, + "y": 100, + "width": 40, + "height": 20 + }, + "viewport": { + "kind": "missing", + "reason": "not-provided" + }, + "hittable": false, + "nodeRectGuardPasses": true + }, + { + "name": "a viewport declared invalid because a box arrived that cannot be one fails closed the same way", + "enabled": true, + "node": { + "x": 100, + "y": 100, + "width": 40, + "height": 20 + }, + "viewport": { + "kind": "missing", + "reason": "invalid" + }, + "hittable": false, + "nodeRectGuardPasses": true + }, + { + "name": "a derived box the capture read from its own root still answers containment", + "enabled": true, + "node": { + "x": 100, + "y": 100, + "width": 40, + "height": 20 + }, + "viewport": { + "kind": "derived", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": true, + "nodeRectGuardPasses": true + }, + { + "name": "a derived box still refuses a node whose center is off that box", + "enabled": true, + "node": { + "x": 900, + "y": 10, + "width": 40, + "height": 20 + }, + "viewport": { + "kind": "derived", + "rect": { + "x": 0, + "y": 0, + "width": 320, + "height": 240 + } + }, + "hittable": false, + "nodeRectGuardPasses": true + } + ] +} diff --git a/packages/kernel/src/rect.ts b/packages/kernel/src/rect.ts index fe25acfe7d..be25550213 100644 --- a/packages/kernel/src/rect.ts +++ b/packages/kernel/src/rect.ts @@ -29,13 +29,22 @@ export function containsPoint(rect: Rect, x: number, y: number): boolean { /** * The shared `hittable` predicate every iOS snapshot producer publishes (#1933): an enabled node - * with a positive frame whose center falls inside the viewport. It is the TypeScript twin of the - * runner's Swift `SnapshotGeometry.isGeometricallyActionable`, including `CGRect.contains`'s + * with a positive finite frame whose center falls inside the viewport. It is the TypeScript twin of + * the runner's Swift `SnapshotGeometry.isGeometricallyActionable`, including `CGRect.contains`'s * half-open right/bottom edges — a center landing exactly on the viewport's right or bottom edge is - * not hittable on either producer. The host AX bridge derives the source bit from the node's own - * frame and the fold intersects it with the clipped frame, so a `hittable:` selector cannot tell the - * two producers apart. Kept here so both packages read one definition rather than each re-encoding - * the rule. + * not hittable on either producer — and including the node-rect precondition, which Swift used to + * spell null/empty and therefore called a negative-width or infinite box actionable here and not + * there (#2891). `contracts/fixtures/snapshot-actionability-policy.json` pins both sides. + * + * `viewport` is total by construction: a caller only reaches this once `resolveViewportEvidence` in + * `packages/capture-kit/src/ios-snapshot-engine/invariants.ts` has a positive finite rect to hand + * over, and it throws `missing-viewport`/`invalid-viewport` otherwise. That refusal is this + * predicate's own unknown-viewport case, and it fails in the same direction as the runner's, which + * declares the state as `SnapshotViewport.missing` and publishes no actionability (#2891). + * + * The host AX bridge derives the source bit from the node's own frame and the fold intersects it + * with the clipped frame, so a `hittable:` selector cannot tell the two producers apart. Kept here + * so both packages read one definition rather than each re-encoding the rule. */ export function isGeometricallyActionable( enabled: boolean, diff --git a/scripts/ios-snapshot-differential.test.ts b/scripts/ios-snapshot-differential.test.ts index a8c8d30414..399ca6792b 100644 --- a/scripts/ios-snapshot-differential.test.ts +++ b/scripts/ios-snapshot-differential.test.ts @@ -1,6 +1,12 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import { test } from 'node:test'; import fc from 'fast-check'; +import type { IosViewportEvidence } from '@agent-device/contracts/ios-snapshot'; +import { isGeometricallyActionable, isPositiveFiniteRect } from '@agent-device/kernel/rect'; +import type { Rect } from '@agent-device/kernel/snapshot'; +import { resolveViewportEvidence } from '../packages/capture-kit/src/ios-snapshot-engine/invariants.ts'; import { compareDifferentialCases, swiftToolchainAvailable, @@ -124,3 +130,102 @@ function assertWithinKillCriterion(startedAt: number, seed: number): void { String(seed), ); } + +type ActionabilityRect = Readonly<{ x: number; y: number; width: number; height: number }>; +type ActionabilityViewport = + | Readonly<{ kind: 'reported' | 'derived'; rect: ActionabilityRect }> + | Readonly<{ kind: 'missing'; reason: 'not-provided' | 'invalid' }>; +type ActionabilityVector = Readonly<{ + name: string; + enabled: boolean; + node: ActionabilityRect | Readonly<{ infinite: true }>; + viewport: ActionabilityViewport; + hittable: boolean; + nodeRectGuardPasses: boolean; +}>; + +const ACTIONABILITY_POLICY_PATH = path.resolve( + import.meta.dirname, + '..', + 'contracts', + 'fixtures', + 'snapshot-actionability-policy.json', +); + +/** The box a platform hands back when it resolved none — JSON has no literal for infinity. */ +const INFINITE_RECT: Rect = { + x: Number.NEGATIVE_INFINITY, + y: Number.NEGATIVE_INFINITY, + width: Number.POSITIVE_INFINITY, + height: Number.POSITIVE_INFINITY, +}; + +function readActionabilityVectors(): readonly ActionabilityVector[] { + const table = JSON.parse(fs.readFileSync(ACTIONABILITY_POLICY_PATH, 'utf8')) as { + cases: readonly ActionabilityVector[]; + }; + assert.ok(table.cases.length > 0, 'actionability vector table must not be empty'); + assert.equal( + new Set(table.cases.map((vector) => vector.name)).size, + table.cases.length, + 'actionability vector names must be unique', + ); + return table.cases; +} + +function toRect(node: ActionabilityVector['node']): Rect { + return 'infinite' in node ? INFINITE_RECT : node; +} + +function missingViewportReason(reason: 'not-provided' | 'invalid'): string { + return reason === 'invalid' ? 'invalid-viewport' : 'missing-viewport'; +} + +// The Swift twin of these same rows is ActionabilityPolicyTests in +// apple/snapshot-presentation/Tests, run by `swift test --package-path apple/snapshot-presentation` +// in this very command. The fold differential above cannot carry them: the host engine refuses to +// fold a regular presentation at all without a positive finite viewport (`resolveViewportEvidence`), +// so an unknown viewport has no TypeScript fold outcome to compare a runner outcome against. +test('the shared hittable predicate agrees with every golden actionability vector', () => { + for (const vector of readActionabilityVectors()) { + const node = toRect(vector.node); + assert.equal( + isPositiveFiniteRect(node), + vector.nodeRectGuardPasses, + `${vector.name}: node-rect guard`, + ); + if (vector.viewport.kind === 'missing') { + const evidence: IosViewportEvidence = vector.viewport; + const expectedReason = missingViewportReason(vector.viewport.reason); + assert.throws( + () => resolveViewportEvidence(evidence), + (error: unknown) => (error as { reason?: string }).reason === expectedReason, + `${vector.name}: the host declines the capture rather than answer the predicate`, + ); + assert.equal(vector.hittable, false, `${vector.name}: the unknown viewport fails closed`); + continue; + } + assert.equal( + isGeometricallyActionable(vector.enabled, node, vector.viewport.rect), + vector.hittable, + vector.name, + ); + } +}); + +test('the actionability table covers every viewport kind without a vacuous missing row', () => { + const vectors = readActionabilityVectors(); + assert.deepEqual([...new Set(vectors.map((vector) => vector.viewport.kind))].sort(), [ + 'derived', + 'missing', + 'reported', + ]); + for (const vector of vectors) { + if (vector.viewport.kind !== 'missing') continue; + assert.equal( + vector.nodeRectGuardPasses, + true, + `${vector.name}: a missing-viewport row needs a node the guard accepts`, + ); + } +}); From b7be9b44785b02c531e61ff1178a66b2cc69b8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 15:12:20 +0200 Subject: [PATCH 3/7] refactor(ios): plot the quarter-turn test through the shared rect predicate `SnapshotGeometrySpace.isPlottable` and `SnapshotGeometry.isPositiveFinite` were the same five comparisons, written twice in one package, which is how #2891 started: one question with two encodings that can drift apart. `.null`, an inverted box, and an infinite box are refused identically by both, so the coordinate-space table needs no new rows. --- .../SnapshotCoordinateSpace.swift | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift index 85c89b519b..13c321b545 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift @@ -145,7 +145,9 @@ public enum SnapshotGeometrySpace: Equatable { } private static func isQuarterTurned(_ frame: CGRect, relativeTo appFrame: CGRect) -> Bool { - guard isPlottable(frame), isPlottable(appFrame), + // The quarter-turn test asks the same question the `hittable` predicate asks before it computes a + // center: is this a box that can be plotted at all. It is one predicate, not two that can drift. + guard SnapshotGeometry.isPositiveFinite(frame), SnapshotGeometry.isPositiveFinite(appFrame), abs(appFrame.width - appFrame.height) > quarterTurnTolerance else { return false @@ -153,12 +155,6 @@ public enum SnapshotGeometrySpace: Equatable { return abs(frame.width - appFrame.height) <= quarterTurnTolerance && abs(frame.height - appFrame.width) <= quarterTurnTolerance } - - private static func isPlottable(_ frame: CGRect) -> Bool { - frame.origin.x.isFinite && frame.origin.y.isFinite - && frame.width.isFinite && frame.height.isFinite - && frame.width > 0 && frame.height > 0 - } } extension SnapshotGeometrySpace { @@ -169,8 +165,8 @@ extension SnapshotGeometrySpace { ) -> [RawAXNode] { let carriers = SnapshotVisibilityFold.visibilityExemptCarrierTypes // No viewport box means no app frame to be quarter-turned relative to either. `.null` is the box - // `isPlottable` refuses, so this pass turns nothing — the outcome `CGRect.infinite` produced - // before the fact carried the absence. + // `SnapshotGeometry.isPositiveFinite` refuses, so this pass turns nothing — the outcome + // `CGRect.infinite` produced before the fact carried the absence. let appFrame = viewport.rect ?? .null var spaces = [SnapshotGeometrySpace](repeating: .appOrientation, count: nodes.count) var result: [RawAXNode] = [] From 5aa141592f69859f385743394d2a0df359154669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 15:27:54 +0200 Subject: [PATCH 4/7] fix(ios): make the viewport type the only place a box becomes a fact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SnapshotViewport.reported(box:)` normalised an unusable box to `.missing(reason: .invalid)`, and the commit said no caller had to re-check what it was handed — while `.reported(rect:)` stayed a public case that accepted `CGRect.infinite` on the way past that claim. The associated value is now a box with an internal initialiser, so outside this package a viewport fact is only reachable through the two factories, and the case labels cannot be named around them. MSG2 --- .../RunnerTests+AXSnapshotFallbackTests.swift | 4 +-- ...nnerTests+PrivateAXPresentationTests.swift | 4 +-- ...RunnerTests+SnapshotCapturePlanTests.swift | 2 +- ...RunnerTests+SnapshotHittabilityTests.swift | 2 +- ...SnapshotPresentationConformanceTests.swift | 2 +- ...ts+SnapshotPresentationGeometryTests.swift | 4 +-- ...s+SnapshotPresentationInvariantTests.swift | 10 +++---- ...unnerTests+SnapshotPresentationTests.swift | 30 +++++++++---------- ...nerTests+SnapshotVisibilityFoldTests.swift | 2 +- .../SnapshotModels.swift | 27 ++++++++++++----- .../main.swift | 2 +- .../ConformanceTests.swift | 2 +- .../CoordinateSpaceTests.swift | 8 ++--- .../InvariantTests.swift | 2 +- .../RegularDepthTests.swift | 2 +- 15 files changed, 58 insertions(+), 45 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXSnapshotFallbackTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXSnapshotFallbackTests.swift index 3400efb95d..d41f514921 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXSnapshotFallbackTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXSnapshotFallbackTests.swift @@ -464,7 +464,7 @@ extension RunnerTests { interactiveOnly: true, customActions: false) let acquired = SnapshotGeometrySpace.normalized( nodes: privateAXAcquisition(rawRoot: tree, hint: hint), - viewport: .reported(rect: viewport), + viewport: .reported(box: viewport), interfaceOrientation: RunnerInterfaceOrientation.portrait ) // Acquisition serializes the drawer too; the shared fold is what hides it (#1797). @@ -472,7 +472,7 @@ extension RunnerTests { let capture = try SnapshotPresentation.presentRegular( SnapshotAcquisition( - hint: hint, nodes: acquired, truncated: false, effectiveDepth: nil, viewport: .reported(rect: 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 ) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+PrivateAXPresentationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+PrivateAXPresentationTests.swift index f527d4518c..bbb081572f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+PrivateAXPresentationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+PrivateAXPresentationTests.swift @@ -43,7 +43,7 @@ extension RunnerTests { return try SnapshotPresentation.presentRegular( SnapshotAcquisition( hint: hint, nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: viewport), + viewport: .reported(box: viewport), interfaceOrientation: RunnerInterfaceOrientation.portrait), options: PresentationOptions( interactiveOnly: interactiveOnly, depth: nil, scope: nil, raw: false), @@ -60,7 +60,7 @@ extension RunnerTests { ) -> [RawAXNode] { SnapshotGeometrySpace.normalized( nodes: privateAXAcquisition(rawRoot: rawRoot, hint: hint), - viewport: .reported(rect: viewport), + viewport: .reported(box: viewport), interfaceOrientation: interfaceOrientation ) } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift index 3a508bb506..87d3ad061e 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift @@ -282,7 +282,7 @@ extension RunnerTests { nodes: [], truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 402, height: 874)) + viewport: .reported(box: CGRect(x: 0, y: 0, width: 402, height: 874)) ), options: options ) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotHittabilityTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotHittabilityTests.swift index 4349fc6530..89828f62f7 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotHittabilityTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotHittabilityTests.swift @@ -97,7 +97,7 @@ extension RunnerTests { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) + viewport: .reported(box: CGRect(x: 0, y: 0, width: 100, height: 100)) ), options: options ) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationConformanceTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationConformanceTests.swift index aa82a8c173..2c16b49851 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationConformanceTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationConformanceTests.swift @@ -97,7 +97,7 @@ extension RunnerTests { }, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: fixture.viewport.cgRect) + viewport: .reported(box: fixture.viewport.cgRect) ) let options = PresentationOptions( interactiveOnly: false, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationGeometryTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationGeometryTests.swift index 1e15593a2b..db52ad5d2f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationGeometryTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationGeometryTests.swift @@ -6,7 +6,7 @@ extension RunnerTests { func testEffectiveGeometryIntersectsViewportAndAncestorClip() { let effective = SnapshotGeometry.effectiveFrame( reportedFrame: CGRect(x: 350, y: 80, width: 100, height: 100), - viewport: .reported(rect: 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) ) @@ -17,7 +17,7 @@ extension RunnerTests { let reported = CGRect(x: 500, y: 120, width: 100, height: 44) let effective = SnapshotGeometry.effectiveFrame( reportedFrame: reported, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 402, height: 874)), + viewport: .reported(box: CGRect(x: 0, y: 0, width: 402, height: 874)), ancestorClip: nil ) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationInvariantTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationInvariantTests.swift index 241d687664..9aeea4f490 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationInvariantTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationInvariantTests.swift @@ -97,7 +97,7 @@ extension RunnerTests { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: viewport) + viewport: .reported(box: viewport) ) } @@ -147,7 +147,7 @@ extension RunnerTests { ], truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 320, height: 240)) + viewport: .reported(box: CGRect(x: 0, y: 0, width: 320, height: 240)) ) let options = PresentationOptions( @@ -203,7 +203,7 @@ extension RunnerTests { ], truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 320, height: 240)) + viewport: .reported(box: CGRect(x: 0, y: 0, width: 320, height: 240)) ) let options = PresentationOptions( @@ -254,7 +254,7 @@ extension RunnerTests { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) + viewport: .reported(box: CGRect(x: 0, y: 0, width: 100, height: 100)) ), options: options ).nodes) @@ -309,7 +309,7 @@ extension RunnerTests { XCTAssertThrowsError( try SnapshotPresentationInvariant.validateRegular( folded, - viewport: .reported(rect: viewport), + viewport: .reported(box: viewport), policy: .cursorProjected ) ) { error in diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift index bbb4a3a8a6..6af8e4e9e2 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift @@ -40,7 +40,7 @@ extension RunnerTests { truncated: 0, blocked: false ), - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) + viewport: .reported(box: CGRect(x: 0, y: 0, width: 100, height: 100)) ), options: PresentationOptions( interactiveOnly: true, @@ -113,7 +113,7 @@ extension RunnerTests { projection: .regular, depth: nil, regularPresentedDepth: nil, interactiveOnly: false, customActions: false), nodes: acquired, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 1_000, height: 1_000))), + viewport: .reported(box: CGRect(x: 0, y: 0, width: 1_000, height: 1_000))), options: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false) ).nodes ) @@ -124,7 +124,7 @@ extension RunnerTests { projection: .regular, depth: nil, regularPresentedDepth: nil, interactiveOnly: true, customActions: false), nodes: acquired, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 1_000, height: 1_000))), + viewport: .reported(box: CGRect(x: 0, y: 0, width: 1_000, height: 1_000))), options: PresentationOptions(interactiveOnly: true, depth: nil, scope: nil, raw: false) ).nodes ) @@ -148,7 +148,7 @@ extension RunnerTests { projection: .raw, depth: nil, regularPresentedDepth: nil, interactiveOnly: false, customActions: false), nodes: acquired, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 1_000, height: 1_000)) ), + viewport: .reported(box: CGRect(x: 0, y: 0, width: 1_000, height: 1_000)) ), options: PresentationOptions(interactiveOnly: true, depth: nil, scope: nil, raw: true) ).nodes ) @@ -177,7 +177,7 @@ extension RunnerTests { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) + viewport: .reported(box: CGRect(x: 0, y: 0, width: 100, height: 100)) ) let presented = try SnapshotPresentation.presentRegular( @@ -212,7 +212,7 @@ extension RunnerTests { nodes: acquired, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: viewport) + viewport: .reported(box: viewport) ), options: regularOptions ).nodes @@ -232,7 +232,7 @@ extension RunnerTests { nodes: acquired, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) + viewport: .reported(box: CGRect(x: 0, y: 0, width: 100, height: 100)) ), options: rawOptions ).nodes @@ -287,7 +287,7 @@ extension RunnerTests { ], truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 1_000, height: 1_000)) + viewport: .reported(box: CGRect(x: 0, y: 0, width: 1_000, height: 1_000)) ) let options = PresentationOptions( interactiveOnly: true, @@ -314,7 +314,7 @@ extension RunnerTests { nodes: acquisition.nodes, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) + viewport: .reported(box: CGRect(x: 0, y: 0, width: 100, height: 100)) ), options: PresentationOptions( interactiveOnly: true, @@ -375,11 +375,11 @@ extension RunnerTests { let regularAcquisition = SnapshotAcquisition( hint: SnapshotPresentation.captureHint(for: regularRequest), nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100))) + viewport: .reported(box: CGRect(x: 0, y: 0, width: 100, height: 100))) let rawAcquisition = SnapshotAcquisition( hint: SnapshotPresentation.captureHint(for: rawRequest), nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100))) + viewport: .reported(box: CGRect(x: 0, y: 0, width: 100, height: 100))) let regularCaptureForRawRequest = try SnapshotPresentation.present( regularAcquisition, options: rawRequest) @@ -472,7 +472,7 @@ extension RunnerTests { ], truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) + viewport: .reported(box: CGRect(x: 0, y: 0, width: 100, height: 100)) ), options: options ) @@ -554,7 +554,7 @@ extension RunnerTests { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: viewport) + viewport: .reported(box: viewport) ) let presented = try XCTUnwrap( SnapshotPresentation.present(acquisition, options: options)?.nodes) @@ -598,7 +598,7 @@ extension RunnerTests { ] let normalized = SnapshotGeometrySpace.normalized( nodes: acquired, - viewport: .reported(rect: viewport), + viewport: .reported(box: viewport), interfaceOrientation: RunnerInterfaceOrientation.landscapeRight ) let options = PresentationOptions(interactiveOnly: false, depth: 3, scope: nil, raw: false) @@ -610,7 +610,7 @@ extension RunnerTests { nodes: normalized, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: viewport) + viewport: .reported(box: viewport) ), options: options )?.nodes diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift index 432ecf95f1..ee6c4400dd 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift @@ -26,7 +26,7 @@ extension RunnerTests { ) -> [SnapshotPresentationNode] { SnapshotVisibilityFold.fold( nodes, - viewport: .reported(rect: viewport), + viewport: .reported(box: viewport), interactiveOnly: interactiveOnly, policy: policy ) diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift index 9c22cd6f35..10d439cb0d 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift @@ -158,19 +158,32 @@ public struct PresentationOptions: Equatable { /// `CGRect.infinite` crossing this boundary read as "everything is actionable" on the runner and as /// "publish nothing" on the host, which is the same state resolved in two directions. public enum SnapshotViewport: Equatable { + /// A box that `SnapshotGeometry.isPositiveFinite` has already accepted. The initialiser is internal, + /// which is what keeps this an enumerated fact instead of a checked suggestion: outside this package + /// the only way to put a box inside a viewport fact is `reported(box:)` or `derived(box:)` below, so + /// no caller can name a case around them and hand over the sentinel again (#2891). + public struct Box: Equatable { + public let rect: CGRect + + init(positiveFinite rect: CGRect) { + self.rect = rect + } + } + /// The platform's own box for the app's surface. - case reported(rect: CGRect) + case reported(Box) /// A box the capture inferred for itself out of its own root element instead of a screen read. It /// clips and contains like a reported box, and it never anchors a rotation: the tier that produces /// it reports no interface orientation beside it (#2612). - case derived(rect: CGRect) + case derived(Box) /// No box. See `SnapshotGeometry.isGeometricallyActionable` for the one policy this answers. case missing(reason: MissingReason) public enum MissingReason: Equatable { /// Nothing was read: the read was skipped, or it raised. case notProvided - /// A box arrived that cannot be a viewport — null, empty, or non-finite. + /// A box arrived that cannot be a viewport: null, empty, inverted, or non-finite, which is what + /// `SnapshotGeometry.isPositiveFinite` refuses. case invalid } @@ -178,8 +191,8 @@ public enum SnapshotViewport: Equatable { /// box may substitute an unbounded one for the absence of one. public var rect: CGRect? { switch self { - case .reported(let rect), .derived(let rect): - return rect + case .reported(let box), .derived(let box): + return box.rect case .missing: return nil } @@ -189,13 +202,13 @@ public enum SnapshotViewport: Equatable { /// becomes `.missing(reason: .invalid)` here, at the one place a box becomes a viewport, so no /// consumer has to re-check what it was handed. public static func reported(box: CGRect) -> SnapshotViewport { - SnapshotGeometry.isPositiveFinite(box) ? .reported(rect: box) : .missing(reason: .invalid) + SnapshotGeometry.isPositiveFinite(box) ? .reported(Box(positiveFinite: box)) : .missing(reason: .invalid) } /// Declares the capture's own root box as its viewport. Same refusal as `reported(box:)`: an /// unusable root box is no box at all. public static func derived(box: CGRect) -> SnapshotViewport { - SnapshotGeometry.isPositiveFinite(box) ? .derived(rect: box) : .missing(reason: .invalid) + SnapshotGeometry.isPositiveFinite(box) ? .derived(Box(positiveFinite: box)) : .missing(reason: .invalid) } } diff --git a/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift b/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift index 33e281c9bf..ef4b796b84 100644 --- a/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift +++ b/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift @@ -79,7 +79,7 @@ private func acquisition(for input: ConformanceInput) -> SnapshotAcquisition { // throws `missing-viewport` before folding (`invariants.ts`), so an unknown viewport has no // TypeScript outcome to compare a fold against; the predicate's unknown-viewport rows belong to // contracts/fixtures/snapshot-actionability-policy.json instead (#2891). - viewport: .reported(rect: input.viewport.cgRect) + viewport: .reported(box: input.viewport.cgRect) ) } diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ConformanceTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ConformanceTests.swift index 12caa23669..d99fdda405 100644 --- a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ConformanceTests.swift +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ConformanceTests.swift @@ -49,7 +49,7 @@ final class ConformanceTests: XCTestCase { ], truncated: false, effectiveDepth: nil, - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 100, height: 100)) + viewport: .reported(box: CGRect(x: 0, y: 0, width: 100, height: 100)) ), options: options, policy: .cursorProjected diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/CoordinateSpaceTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/CoordinateSpaceTests.swift index be95c60eb1..3d968d8e53 100644 --- a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/CoordinateSpaceTests.swift +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/CoordinateSpaceTests.swift @@ -422,7 +422,7 @@ final class CoordinateSpaceTests: XCTestCase { let expected = (namesQuarterTurn && !squareApp) ? testCase.oriented : testCase.native let normalized = SnapshotGeometrySpace.normalized( nodes: turnedSubtree(app: app, reportedLeaf: testCase.native.cgRect), - viewport: .reported(rect: app), + viewport: .reported(box: app), interfaceOrientation: testCase.interfaceOrientation ) XCTAssertEqual(normalized.count, 4) @@ -444,7 +444,7 @@ final class CoordinateSpaceTests: XCTestCase { ] let normalized = SnapshotGeometrySpace.normalized( nodes: acquired, - viewport: .reported(rect: app), + viewport: .reported(box: app), interfaceOrientation: RunnerInterfaceOrientation.landscapeRight ) XCTAssertEqual( @@ -471,7 +471,7 @@ final class CoordinateSpaceTests: XCTestCase { ] let normalized = SnapshotGeometrySpace.normalized( nodes: acquired, - viewport: .reported(rect: app), + viewport: .reported(box: app), interfaceOrientation: RunnerInterfaceOrientation.unknown ) XCTAssertEqual(normalized.map(\.rect), acquired.map(\.rect)) @@ -484,7 +484,7 @@ final class CoordinateSpaceTests: XCTestCase { XCTAssertEqual( SnapshotGeometrySpace.normalized( nodes: [], - viewport: .reported(rect: CGRect(x: 0, y: 0, width: 874, height: 402)), + viewport: .reported(box: CGRect(x: 0, y: 0, width: 874, height: 402)), interfaceOrientation: RunnerInterfaceOrientation.landscapeRight ), [] diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/InvariantTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/InvariantTests.swift index b10d8c259f..2a2eee8f48 100644 --- a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/InvariantTests.swift +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/InvariantTests.swift @@ -28,7 +28,7 @@ final class InvariantTests: XCTestCase { let stats = try SnapshotPresentationInvariant.validateRegularWithStats( nodes, - viewport: .reported(rect: viewport), + viewport: .reported(box: viewport), policy: .cursorProjected ) diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/RegularDepthTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/RegularDepthTests.swift index 912fcfca04..5589a9c304 100644 --- a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/RegularDepthTests.swift +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/RegularDepthTests.swift @@ -21,7 +21,7 @@ final class RegularDepthTests: XCTestCase { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: .reported(rect: viewport) + viewport: .reported(box: viewport) ) let result = try XCTUnwrap(SnapshotPresentation.present(acquisition, options: options)) From 02691bf21eda8e45db98524b2b0836fabe4c8731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 15:27:57 +0200 Subject: [PATCH 5/7] test(ios): write down the one input the two predicates cannot share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table replayed `{"infinite": true}` as `CGRect.infinite` in Swift and as a rect with infinite components in TypeScript, so its infinity row never put the same number in front of both sides. `CGRect.infinite` is made of finite Doubles: TypeScript would call those components actionable, and only the platform that can name the value can refuse it. The row is now Swift-only and carries `asymmetry` naming that, every row declares both `swift` and `typescript`, and a shared non-finite row replaces it with numbers both sides actually see. Also states three claims the code had not been checked against: a published `hittable: false` is retained rather than filtered out, the runner's viewport fact decides only runner bits because the host re-derives its own from the capture's root node, and `viewportFromRoot` — not only `resolveViewportEvidence` — is what makes the TypeScript `viewport` argument total. --- .../ActionabilityPolicyTests.swift | 38 +- .../snapshot-actionability-policy.json | 344 +++++------------- .../adr/0004-ios-snapshot-backend-strategy.md | 21 +- packages/kernel/src/rect.ts | 28 +- scripts/ios-snapshot-differential.test.ts | 44 ++- 5 files changed, 190 insertions(+), 285 deletions(-) diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift index 1d991de95f..971d8ed0d8 100644 --- a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift @@ -14,11 +14,13 @@ final class ActionabilityPolicyTests: XCTestCase { let cases: [PolicyCase] } - /// A rect as JSON can carry one. Infinity has no JSON spelling, so the unusable box a platform - /// hands back is named `{"infinite": true}` (`window-coordinate-space.json` spells it the same). + /// A rect as JSON can carry one. Infinity has no JSON spelling, so the two unusable boxes a platform + /// can hand back are named: `{"infinite": true}` is `CGRect.infinite`, the box Apple returns for + /// "resolved none" (`window-coordinate-space.json` spells it the same), and `{"nonFinite": true}` is + /// a box with actual infinite components, which is what the host's frame decoder refuses. private struct RectBox: Decodable { private enum CodingKeys: String, CodingKey { - case x, y, width, height, infinite + case x, y, width, height, infinite, nonFinite } let cgRect: CGRect @@ -29,6 +31,15 @@ final class ActionabilityPolicyTests: XCTestCase { self.cgRect = .infinite return } + guard try container.decodeIfPresent(Bool.self, forKey: .nonFinite) != true else { + self.cgRect = CGRect( + x: -.infinity, + y: -.infinity, + width: .infinity, + height: .infinity + ) + return + } self.cgRect = CGRect( x: try container.decode(Double.self, forKey: .x), y: try container.decode(Double.self, forKey: .y), @@ -93,12 +104,26 @@ final class ActionabilityPolicyTests: XCTestCase { } private struct PolicyCase: Decodable { + private enum CodingKeys: String, CodingKey { + case name, swift, typescript, asymmetry, enabled, node, viewport, hittable, nodeRectGuardPasses + } + let name: String + let swift: Bool + let typescript: Bool + let asymmetry: String? let enabled: Bool let node: RectBox let viewport: ViewportFact let hittable: Bool let nodeRectGuardPasses: Bool + + /// A row one language skips is a written-down divergence, and a divergence without a reason is how + /// two implementations start disagreeing quietly again: a shared row carries no reason and a skipped + /// row carries exactly one. + var declaresItsAsymmetry: Bool { + (swift && typescript) != (asymmetry?.isEmpty == false) + } } func testActionabilityPolicyAgreesWithEveryGoldenVector() throws { @@ -110,6 +135,11 @@ final class ActionabilityPolicyTests: XCTestCase { "vector names must be unique" ) for testCase in table.cases { + XCTAssertTrue( + testCase.declaresItsAsymmetry, + "\(testCase.name): a row both languages do not share must name the asymmetry" + ) + guard testCase.swift else { continue } XCTAssertTrue( testCase.viewport.matchesDeclaredKind, "\(testCase.name): declared \(testCase.viewport.declaredKind) must survive declaration" @@ -135,7 +165,7 @@ final class ActionabilityPolicyTests: XCTestCase { /// every declared kind has to be present, and no `missing` row may rest on a node rect that the /// guard already refuses — that would make the row's `false` say nothing about the policy. func testActionabilityPolicyCoversEveryViewportKindWithoutAVacuousMissingRow() throws { - let cases = try loadActionabilityPolicyTable().cases + let cases = try loadActionabilityPolicyTable().cases.filter(\.swift) XCTAssertEqual(Set(cases.map(\.viewport.declaredKind)), ["reported", "derived", "missing"]) for testCase in cases where testCase.viewport.declaredKind == "missing" { XCTAssertTrue( diff --git a/contracts/fixtures/snapshot-actionability-policy.json b/contracts/fixtures/snapshot-actionability-policy.json index 117eac3054..5055ddf606 100644 --- a/contracts/fixtures/snapshot-actionability-policy.json +++ b/contracts/fixtures/snapshot-actionability-policy.json @@ -1,368 +1,194 @@ { - "description": "Golden vector table for the shared `hittable` predicate (#1933), pinned for the input shapes the fixed 320x240 fold fixture in ios-snapshot-engine-conformance.json cannot reach (#2891). RULE: a node is actionable when it is enabled, its own rect is positive and finite, and its center falls inside the viewport box, half-open on the right and bottom edges (inclusive on the left and top). Two implementations: isGeometricallyActionable in apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift (replayed by ActionabilityPolicyTests, and reached on device through SnapshotGeometrySpace.normalized and SnapshotVisibilityFold.fold) and isGeometricallyActionable in packages/kernel/src/rect.ts (replayed by scripts/ios-snapshot-differential.test.ts). THE VIEWPORT IS A DECLARED FACT, not a rectangle: `reported` is the platform's box for the app's surface, `derived` is a box the capture inferred for itself out of its own root element, and `missing` carries `not-provided` (nothing was read) or `invalid` (a box arrived that cannot be one). These are the three cases of IosViewportEvidence in packages/contracts/src/ios-snapshot.ts, and the old spelling \u2014 CGRect.infinite as the viewport \u2014 made every node actionable on the runner while the host published nothing, which is one state resolved in two directions. POLICY FOR AN UNKNOWN VIEWPORT, stated once at the Swift decision site and failing CLOSED: `missing` publishes no actionability, because `hittable` claims a tap at the center lands and a capture with no box cannot support that; `hittable: false` is visible to the caller as an empty interactive result, an unsupported `true` is silent. The host reaches the same direction with a stronger instrument: resolveViewportEvidence in packages/capture-kit/src/ios-snapshot-engine/invariants.ts throws `missing-viewport`/`invalid-viewport` and refuses to fold a regular presentation at all, so the TypeScript predicate has no case for the state \u2014 its viewport parameter is total by construction. That is why `missing` rows are asserted differently per language: Swift asserts the predicate answers false, TypeScript asserts the capture-level refusal fires and the node-rect guard claim holds. `derived` keeps answering containment on both sides: it is a screen box the capture read out of its own root element (the runner's private AX tier falls back to it, and a host provider adapter reaches the same shape from an element tree), and the host's decline for that kind is a source-stage decision about who may claim the bit \u2014 `publishDerivedHittability` in packages/platform-apple/src/snapshot-source/tree.ts runs only for a `reported` viewport \u2014 not a second answer from the predicate. Widening the fail-closed case to `derived` was rejected because `hittable` is load-bearing downstream: the #2638 wrapper verdict reads a declared false as evidence a wrapper is inert, so the case that stops claiming actionability stays exactly the case with no box at all. WHY SOME ROWS COME IN PAIRS: several rows state an expectation both sides already agreed on before #2891, so a bare false would be vacuous. Each such row has a sibling reaching the SAME center through a box that passes the guard and answering true, which shows the guard, not containment, is what decides. Two of them were genuine divergences: `CGRect.infinite` is built of finite Doubles (\u00b1DBL_MAX/2, DBL_MAX) so its center is (0,0) and the Swift side called it actionable inside any viewport, and a negative width passes a null/empty check while its center lands inside, which the private AX bridge's JSON frame parser can hand over. Infinity has no JSON spelling, so the unusable box is named {\"infinite\": true} and decodes to CGRect.infinite on the Swift side and to an infinite rect on the TypeScript side, as in window-coordinate-space.json. Each row also carries `nodeRectGuardPasses`: whether `node` satisfies that positive-finite precondition, asserted in both languages so a drift in either guard names itself.", + "description": "Golden vector table for the shared `hittable` predicate (#1933), pinned for the input shapes the fixed 320x240 fold fixture in ios-snapshot-engine-conformance.json cannot reach (#2891). RULE: a node is actionable when it is enabled, its own rect is positive and finite, and its center falls inside the viewport box, half-open on the right and bottom edges (inclusive on the left and top). Two implementations: isGeometricallyActionable in apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift (replayed by ActionabilityPolicyTests, and reached on device through SnapshotGeometrySpace.normalized and SnapshotVisibilityFold.fold) and isGeometricallyActionable in packages/kernel/src/rect.ts (replayed by scripts/ios-snapshot-differential.test.ts). EVERY ROW declares both `swift` and `typescript`; a row either side skips must carry `asymmetry` naming why, so a divergence is always written down rather than discovered. THE VIEWPORT IS A DECLARED FACT, not a rectangle: `reported` is the platform's box for the app's surface, `derived` is a box the capture inferred for itself out of its own root element, and `missing` carries `not-provided` (nothing was read) or `invalid` (a box arrived that cannot be one). These are the three cases of IosViewportEvidence in packages/contracts/src/ios-snapshot.ts, and the old spelling - CGRect.infinite as the viewport - made every node actionable on the runner while the host published nothing, which is one state resolved in two directions. POLICY FOR AN UNKNOWN VIEWPORT, stated once at the Swift decision site and failing CLOSED: `missing` publishes no actionability, because `hittable` claims a tap at the center lands and a capture with no box cannot support that claim. The bit is published, never filtered: SnapshotVisibilityFoldProjection.shouldInclude and isEligibleForRegularPresentation retain a visible node whatever its bit says, so a retained node carries `hittable: false` and a caller or a `hittable:` selector reads it, while a wrong `true` is a claim nothing contradicts. The host reaches the same direction earlier in its own capture path, where resolveViewportEvidence in packages/capture-kit/src/ios-snapshot-engine/invariants.ts refuses to fold a regular presentation without a positive finite viewport - which is why the TypeScript predicate has no unknown-viewport case, and why this state cannot be compared through the fold differential.", "cases": [ { "name": "a center strictly inside the reported box is actionable", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": 100, - "y": 100, - "width": 40, - "height": 20 - }, - "viewport": { - "kind": "reported", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": 100, "y": 100, "width": 40, "height": 20 }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": true, "nodeRectGuardPasses": true }, { "name": "a center one point short of the right edge is actionable", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": 239, - "y": 110, - "width": 160, - "height": 20 - }, - "viewport": { - "kind": "reported", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": 239, "y": 110, "width": 160, "height": 20 }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": true, "nodeRectGuardPasses": true }, { "name": "a center landing exactly on the right edge is not actionable: the edge is half-open", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": 240, - "y": 110, - "width": 160, - "height": 20 - }, - "viewport": { - "kind": "reported", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": 240, "y": 110, "width": 160, "height": 20 }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": false, "nodeRectGuardPasses": true }, { "name": "a center one point short of the bottom edge is actionable", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": 100, - "y": 159, - "width": 40, - "height": 160 - }, - "viewport": { - "kind": "reported", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": 100, "y": 159, "width": 40, "height": 160 }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": true, "nodeRectGuardPasses": true }, { "name": "a center landing exactly on the bottom edge is not actionable: the edge is half-open", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": 100, - "y": 160, - "width": 40, - "height": 160 - }, - "viewport": { - "kind": "reported", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": 100, "y": 160, "width": 40, "height": 160 }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": false, "nodeRectGuardPasses": true }, { "name": "a center landing exactly on the left edge is actionable: that edge is inclusive", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": -20, - "y": 100, - "width": 40, - "height": 20 - }, - "viewport": { - "kind": "reported", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": -20, "y": 100, "width": 40, "height": 20 }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": true, "nodeRectGuardPasses": true }, { "name": "a center landing exactly on the top edge is actionable: that edge is inclusive", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": 100, - "y": -10, - "width": 40, - "height": 20 - }, - "viewport": { - "kind": "reported", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": 100, "y": -10, "width": 40, "height": 20 }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": true, "nodeRectGuardPasses": true }, { "name": "a disabled node is not actionable even with its center inside the box", + "swift": true, + "typescript": true, "enabled": false, - "node": { - "x": 100, - "y": 100, - "width": 40, - "height": 20 - }, - "viewport": { - "kind": "reported", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": 100, "y": 100, "width": 40, "height": 20 }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": false, "nodeRectGuardPasses": true }, { "name": "a center inside the box reached through a canonical box is actionable", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": 80, - "y": 100, - "width": 20, - "height": 20 - }, - "viewport": { - "kind": "reported", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": 80, "y": 100, "width": 20, "height": 20 }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": true, "nodeRectGuardPasses": true }, { "name": "the same center reached through a negative width is not actionable: the node guard decides, and Swift used to answer actionable", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": 100, - "y": 100, - "width": -20, - "height": 20 - }, - "viewport": { - "kind": "reported", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": 100, "y": 100, "width": -20, "height": 20 }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": false, "nodeRectGuardPasses": false }, { "name": "a center inside the box reached through a zero width is not actionable", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": 100, - "y": 110, - "width": 0, - "height": 20 - }, - "viewport": { - "kind": "reported", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": 100, "y": 110, "width": 0, "height": 20 }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": false, "nodeRectGuardPasses": false }, { "name": "that zero-area box's center reached through a canonical box is actionable", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": 90, - "y": 110, - "width": 20, - "height": 20 - }, - "viewport": { - "kind": "reported", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": 90, "y": 110, "width": 20, "height": 20 }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": true, "nodeRectGuardPasses": true }, { - "name": "a center inside the box reached through a box spelled infinite is not actionable, and Swift used to answer actionable because its center is (0,0)", + "name": "Apple's no-box sentinel is not actionable: CGRect.infinite is made of finite Doubles whose center is (0,0), so only the platform that owns that value can refuse it by identity", + "swift": true, + "typescript": false, + "asymmetry": "Swift decodes {\"infinite\": true} as CGRect.infinite; TypeScript has no such value, and frameFromGuest in packages/platform-apple/src/snapshot-source/tree.ts refuses a frame whose components are not Number.isFinite before the predicate ever sees it, which is the row below. The sentinel's components are finite, so TypeScript would call them actionable: the refusal belongs to the producer that parses an Apple frame, not to a magic value inside the shared rule.", "enabled": true, - "node": { - "infinite": true - }, - "viewport": { - "kind": "reported", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "infinite": true }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, + "hittable": false, + "nodeRectGuardPasses": false + }, + { + "name": "a node rect whose components are not finite is not actionable on either producer, and the component checks refuse it before any center is computed", + "swift": true, + "typescript": true, + "enabled": true, + "node": { "nonFinite": true }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": false, "nodeRectGuardPasses": false }, { "name": "the center a box spelled infinite lands on is actionable when a real box reaches it", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": -10, - "y": -10, - "width": 20, - "height": 20 - }, - "viewport": { - "kind": "reported", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": -10, "y": -10, "width": 20, "height": 20 }, + "viewport": { "kind": "reported", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": true, "nodeRectGuardPasses": true }, { "name": "the same node whose center sits inside a reported box is not actionable once the viewport goes missing: the policy fails closed", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": 100, - "y": 100, - "width": 40, - "height": 20 - }, - "viewport": { - "kind": "missing", - "reason": "not-provided" - }, + "node": { "x": 100, "y": 100, "width": 40, "height": 20 }, + "viewport": { "kind": "missing", "reason": "not-provided" }, "hittable": false, "nodeRectGuardPasses": true }, { "name": "a viewport declared invalid because a box arrived that cannot be one fails closed the same way", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": 100, - "y": 100, - "width": 40, - "height": 20 - }, - "viewport": { - "kind": "missing", - "reason": "invalid" - }, + "node": { "x": 100, "y": 100, "width": 40, "height": 20 }, + "viewport": { "kind": "missing", "reason": "invalid" }, "hittable": false, "nodeRectGuardPasses": true }, { "name": "a derived box the capture read from its own root still answers containment", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": 100, - "y": 100, - "width": 40, - "height": 20 - }, - "viewport": { - "kind": "derived", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": 100, "y": 100, "width": 40, "height": 20 }, + "viewport": { "kind": "derived", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": true, "nodeRectGuardPasses": true }, { "name": "a derived box still refuses a node whose center is off that box", + "swift": true, + "typescript": true, "enabled": true, - "node": { - "x": 900, - "y": 10, - "width": 40, - "height": 20 - }, - "viewport": { - "kind": "derived", - "rect": { - "x": 0, - "y": 0, - "width": 320, - "height": 240 - } - }, + "node": { "x": 900, "y": 10, "width": 40, "height": 20 }, + "viewport": { "kind": "derived", "rect": { "x": 0, "y": 0, "width": 320, "height": 240 } }, "hittable": false, "nodeRectGuardPasses": true } diff --git a/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index 111cccc20d..4ee1a3f8af 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -307,15 +307,18 @@ contract. Inside the runner the viewport is a declared fact and not a rectangle: `SnapshotViewport` carries `reported`, `derived`, or `missing { reason }`, the three cases `IosViewportEvidence` already uses on the host. The runner previously spelled "unknown" as `CGRect.infinite`, and that one state resolved in -opposite directions — every node actionable on the runner, none on the host (#2891). One policy, -stated at the Swift decision site, fails CLOSED: with no viewport box no node is actionable, while the -clip skips and the cumulative-clip invariant is left with no root clip to violate rather than an -unbounded one. The host reaches the same direction earlier and harder, because its engine's -`resolveViewportEvidence` refuses to fold a regular presentation at all — which is why the TypeScript -predicate has no unknown-viewport case and why this state cannot be compared through the fold -differential. `contracts/fixtures/snapshot-actionability-policy.json` pins the predicate for the shapes -the fixed 320x240 fold fixture cannot reach: the half-open right and bottom edges, a node rect neither -language's null/empty check refuses, and an unknown viewport. +opposite directions — every node actionable on the runner, none published by the host's own derived +hittability (#2891). One policy, stated at the Swift decision site, fails CLOSED: with no viewport box +no node is actionable, while the clip skips and the cumulative-clip invariant is left with no root +clip to violate rather than an unbounded one. The fact decides only what the runner publishes: a +capture crosses into the host as nodes alone (`makeSnapshotBackendCapture`), and there the host forms +its own evidence from the capture's root node (`viewportFromRoot`), which is `reported` whenever that +root box is positive and finite. In the host's own capture path the same direction is reached earlier +and harder, because `resolveViewportEvidence` refuses to fold a regular presentation at all — which is +why the TypeScript predicate has no unknown-viewport case and why this state cannot be compared +through the fold differential. `contracts/fixtures/snapshot-actionability-policy.json` pins the +predicate for the shapes the fixed 320x240 fold fixture cannot reach: the half-open right and bottom +edges, a node rect neither language's null/empty check refuses, and an unknown viewport. A regular `--depth` request is a presentation cut, not an acquisition bound. `CaptureHint` keeps raw traversal depth (`--raw --depth`) separate from regular presented depth, but the recursive tree walk diff --git a/packages/kernel/src/rect.ts b/packages/kernel/src/rect.ts index be25550213..44d70405f8 100644 --- a/packages/kernel/src/rect.ts +++ b/packages/kernel/src/rect.ts @@ -1,5 +1,15 @@ import type { Rect } from './snapshot.ts'; +/** + * The rect precondition shared by every iOS snapshot producer's `hittable` claim, and the twin of + * `SnapshotGeometry.isPositiveFinite` on the runner. + * + * It is a rule about numbers, not about values one platform invents. Apple's "resolved none" box, + * `CGRect.infinite`, is built out of finite `Double`s, so no arithmetic here can recognise it; the + * Swift side refuses it by identity because that side can name it. A frame with components that are + * genuinely not finite is refused further upstream, by `frameFromGuest` in + * `packages/platform-apple/src/snapshot-source/tree.ts`, before any predicate is asked (#2891). + */ export function isPositiveFiniteRect(rect: Rect | undefined): rect is Rect { return Boolean( rect && @@ -33,14 +43,18 @@ export function containsPoint(rect: Rect, x: number, y: number): boolean { * the runner's Swift `SnapshotGeometry.isGeometricallyActionable`, including `CGRect.contains`'s * half-open right/bottom edges — a center landing exactly on the viewport's right or bottom edge is * not hittable on either producer — and including the node-rect precondition, which Swift used to - * spell null/empty and therefore called a negative-width or infinite box actionable here and not - * there (#2891). `contracts/fixtures/snapshot-actionability-policy.json` pins both sides. + * spell null/empty and so called a negative-width box, or one whose components are not finite, + * actionable there and not here (#2891). `contracts/fixtures/snapshot-actionability-policy.json` + * pins both sides. * - * `viewport` is total by construction: a caller only reaches this once `resolveViewportEvidence` in - * `packages/capture-kit/src/ios-snapshot-engine/invariants.ts` has a positive finite rect to hand - * over, and it throws `missing-viewport`/`invalid-viewport` otherwise. That refusal is this - * predicate's own unknown-viewport case, and it fails in the same direction as the runner's, which - * declares the state as `SnapshotViewport.missing` and publishes no actionability (#2891). + * `viewport` has no unknown case to handle: every caller hands over a box its own producer declared + * positive and finite — `viewportFromRoot` in `packages/platform-apple/src/snapshot-source/tree.ts` + * before that path publishes the bit at all, and `resolveViewportEvidence` in + * `packages/capture-kit/src/ios-snapshot-engine/invariants.ts`, which throws + * `missing-viewport`/`invalid-viewport`, before the engine folds a regular presentation. Those + * refusals are this predicate's unknown-viewport case, and they fail in the same direction as the + * runner's, which carries the state as `SnapshotViewport.missing` and publishes no actionability + * (#2891). * * The host AX bridge derives the source bit from the node's own frame and the fold intersects it * with the clipped frame, so a `hittable:` selector cannot tell the two producers apart. Kept here diff --git a/scripts/ios-snapshot-differential.test.ts b/scripts/ios-snapshot-differential.test.ts index 399ca6792b..0f8ee350af 100644 --- a/scripts/ios-snapshot-differential.test.ts +++ b/scripts/ios-snapshot-differential.test.ts @@ -132,13 +132,17 @@ function assertWithinKillCriterion(startedAt: number, seed: number): void { } type ActionabilityRect = Readonly<{ x: number; y: number; width: number; height: number }>; +type ActionabilityUnusableRect = Readonly<{ infinite: true }> | Readonly<{ nonFinite: true }>; type ActionabilityViewport = | Readonly<{ kind: 'reported' | 'derived'; rect: ActionabilityRect }> | Readonly<{ kind: 'missing'; reason: 'not-provided' | 'invalid' }>; type ActionabilityVector = Readonly<{ name: string; + swift: boolean; + typescript: boolean; + asymmetry?: string; enabled: boolean; - node: ActionabilityRect | Readonly<{ infinite: true }>; + node: ActionabilityRect | ActionabilityUnusableRect; viewport: ActionabilityViewport; hittable: boolean; nodeRectGuardPasses: boolean; @@ -152,14 +156,23 @@ const ACTIONABILITY_POLICY_PATH = path.resolve( 'snapshot-actionability-policy.json', ); -/** The box a platform hands back when it resolved none — JSON has no literal for infinity. */ -const INFINITE_RECT: Rect = { +/** A box whose components are not numbers anything may plot. JSON has no literal for infinity. */ +const NON_FINITE_RECT: Rect = { x: Number.NEGATIVE_INFINITY, y: Number.NEGATIVE_INFINITY, width: Number.POSITIVE_INFINITY, height: Number.POSITIVE_INFINITY, }; +/** + * A row one language skips is a written-down divergence, and a divergence without a reason is how two + * implementations start disagreeing quietly again: a shared row carries no reason, a skipped row one. + */ +function declaresItsAsymmetry(vector: ActionabilityVector): boolean { + const hasReason = typeof vector.asymmetry === 'string' && vector.asymmetry.length > 0; + return (vector.swift && vector.typescript) !== hasReason; +} + function readActionabilityVectors(): readonly ActionabilityVector[] { const table = JSON.parse(fs.readFileSync(ACTIONABILITY_POLICY_PATH, 'utf8')) as { cases: readonly ActionabilityVector[]; @@ -170,11 +183,30 @@ function readActionabilityVectors(): readonly ActionabilityVector[] { table.cases.length, 'actionability vector names must be unique', ); + for (const vector of table.cases) { + assert.equal(typeof vector.swift, 'boolean', `${vector.name}: row must declare the Swift side`); + assert.equal( + typeof vector.typescript, + 'boolean', + `${vector.name}: row must declare the TypeScript side`, + ); + assert.ok( + declaresItsAsymmetry(vector), + `${vector.name}: a row both languages do not share must name the asymmetry`, + ); + } return table.cases; } function toRect(node: ActionabilityVector['node']): Rect { - return 'infinite' in node ? INFINITE_RECT : node; + if ('nonFinite' in node) return NON_FINITE_RECT; + if ('infinite' in node) { + throw new Error( + "CGRect.infinite is Apple's value and no row reaching TypeScript may stand for it: " + + 'that row belongs to the Swift side alone', + ); + } + return node; } function missingViewportReason(reason: 'not-provided' | 'invalid'): string { @@ -187,7 +219,7 @@ function missingViewportReason(reason: 'not-provided' | 'invalid'): string { // fold a regular presentation at all without a positive finite viewport (`resolveViewportEvidence`), // so an unknown viewport has no TypeScript fold outcome to compare a runner outcome against. test('the shared hittable predicate agrees with every golden actionability vector', () => { - for (const vector of readActionabilityVectors()) { + for (const vector of readActionabilityVectors().filter((row) => row.typescript)) { const node = toRect(vector.node); assert.equal( isPositiveFiniteRect(node), @@ -214,7 +246,7 @@ test('the shared hittable predicate agrees with every golden actionability vecto }); test('the actionability table covers every viewport kind without a vacuous missing row', () => { - const vectors = readActionabilityVectors(); + const vectors = readActionabilityVectors().filter((row) => row.typescript); assert.deepEqual([...new Set(vectors.map((vector) => vector.viewport.kind))].sort(), [ 'derived', 'missing', From f38456de87633544683613e618555b04ae34c6a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 18:08:13 +0200 Subject: [PATCH 6/7] fix(ios): leave hittable absent without a viewport box; only a reported viewport anchors a rotation A capture with no viewport box can still decide a disabled or degenerate node, but not containment, so the runner now omits hittable instead of declaring false, matching the host bridge's undefined bit. The interface orientation moves into SnapshotViewport.reported, so a derived or missing viewport cannot anchor a rotation, and SnapshotGeometrySpace takes the viewport fact rather than a sentinel CGRect. --- .../RunnerTests+AXSnapshotFallback.swift | 34 ++-- .../RunnerTests+Navigation.swift | 14 +- .../RunnerTests+Snapshot.swift | 17 +- .../RunnerTests+SnapshotAcquisition.swift | 33 ++-- .../RunnerTests+SnapshotCapturePlan.swift | 5 +- .../RunnerTests+AXSnapshotFallbackTests.swift | 3 +- ...nnerTests+PrivateAXPresentationTests.swift | 9 +- ...ts+SnapshotCapturePlanOccupancyTests.swift | 3 +- ...unnerTests+SnapshotPresentationTests.swift | 6 +- .../SnapshotCoordinateSpace.swift | 35 ++-- .../SnapshotGeometry.swift | 45 +---- .../SnapshotModels.swift | 64 +++---- .../SnapshotPresentationInvariant.swift | 11 +- .../SnapshotVisibilityFold.swift | 17 +- .../main.swift | 5 +- .../ActionabilityPolicyTests.swift | 108 ++---------- .../CoordinateSpaceTests.swift | 164 +++++++++--------- .../FixtureRect.swift | 41 +++++ .../snapshot-actionability-policy.json | 36 +++- .../adr/0004-ios-snapshot-backend-strategy.md | 24 +-- packages/kernel/src/rect.ts | 33 +--- .../__tests__/snapshot-presentation.test.ts | 27 +++ scripts/ios-snapshot-differential.test.ts | 41 +---- 23 files changed, 340 insertions(+), 435 deletions(-) create mode 100644 apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/FixtureRect.swift diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift index f13899fca7..df933a24ab 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift @@ -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 @@ -259,8 +258,7 @@ extension RunnerTests { customActions: Self.privateAXCustomActionCoverage( response[RunnerAXSnapshotCustomActionsKey] ), - viewport: viewport, - interfaceOrientation: geometry.interfaceOrientation + viewport: viewport ) #else return nil @@ -275,38 +273,32 @@ extension RunnerTests { !hasAbandonedMainThreadWork() && !isSnapshotXCTestChannelPenalized(bundleId: bundleId) } - /// The geometry this tier may anchor a rotation on. The bridge's own root frame is declared - /// `.derived` rather than reported — it is a box this capture inferred for itself, not 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: SnapshotViewport, interfaceOrientation: Int) { + ) -> 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.rect == nil { - 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 } } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift index da185b037e..3ab477ff74 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift @@ -1,3 +1,4 @@ +import AgentDeviceSnapshotPresentation import XCTest extension RunnerTests { @@ -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. @@ -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. diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index 1d8549df0c..f87b5b0648 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -21,9 +21,8 @@ extension RunnerTests { struct SnapshotTraversalContext { let queryRoot: XCUIElement let rootSnapshot: XCUIElementSnapshot + /** Carries which way the app's interface is turned from the device's native space (#2612). */ let viewport: SnapshotViewport - /** Which way the app's interface is turned from the device's native space (#2612). */ - let interfaceOrientation: Int /** * 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 @@ -278,8 +277,7 @@ extension RunnerTests { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: context.viewport, - interfaceOrientation: context.interfaceOrientation + viewport: context.viewport ) } @@ -435,8 +433,7 @@ extension RunnerTests { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: context.viewport, - interfaceOrientation: context.interfaceOrientation + viewport: context.viewport ) } @@ -455,14 +452,13 @@ extension RunnerTests { nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: .missing(reason: .notProvided), - interfaceOrientation: RunnerInterfaceOrientation.unknown + viewport: .missing(reason: .notProvided) ), .completed ) } - let viewport = safeSnapshotViewport(app: app) + let viewport = safeSnapshotViewport(app: app, readingOrientation: false) var seen = Set() var candidates: [RawAXNode] = [] let flatElements = flatInteractiveElements(app: app, deadline: deadline) @@ -522,8 +518,7 @@ extension RunnerTests { nodes: nodes, truncated: outcome == .deadlineExhausted, effectiveDepth: nil, - viewport: viewport, - interfaceOrientation: RunnerInterfaceOrientation.unknown + viewport: viewport ), outcome ) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift index d0063268be..7ebc82596f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift @@ -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 { @@ -70,7 +65,6 @@ extension RunnerTests { queryRoot: app, rootSnapshot: rootSnapshot, viewport: viewport, - interfaceOrientation: interfaceOrientation, keyboardBand: keyboardBand ) } @@ -140,10 +134,18 @@ extension RunnerTests { return nil } - /// The viewport as a declared fact. A read that raises leaves the capture with no box, which is - /// `.missing(reason: .notProvided)` and not a box that contains everything (#2891). - func safeSnapshotViewport(app: XCUIApplication) -> SnapshotViewport { - safely("SNAPSHOT_VIEWPORT", .missing(reason: .notProvided)) { 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 { @@ -233,13 +235,12 @@ extension RunnerTests { return text.isEmpty ? nil : text } - private func snapshotViewport(app: XCUIApplication) -> SnapshotViewport { + 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 - return .reported(box: appFrame) } static func snapshotTraversalIdentity( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index e7f3898652..f1547821ba 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -513,8 +513,7 @@ extension RunnerTests { let normalizedAcquisition = acquisition.replacingNodes( SnapshotGeometrySpace.normalized( nodes: acquisition.nodes, - viewport: acquisition.viewport, - interfaceOrientation: acquisition.interfaceOrientation + viewport: acquisition.viewport ) ) @@ -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 } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXSnapshotFallbackTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXSnapshotFallbackTests.swift index d41f514921..4e4fd9ff44 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXSnapshotFallbackTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXSnapshotFallbackTests.swift @@ -464,8 +464,7 @@ extension RunnerTests { interactiveOnly: true, customActions: false) let acquired = SnapshotGeometrySpace.normalized( nodes: privateAXAcquisition(rawRoot: tree, hint: hint), - viewport: .reported(box: 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")) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+PrivateAXPresentationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+PrivateAXPresentationTests.swift index bbb081572f..3dcd2908dd 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+PrivateAXPresentationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+PrivateAXPresentationTests.swift @@ -43,8 +43,10 @@ extension RunnerTests { return try SnapshotPresentation.presentRegular( SnapshotAcquisition( hint: hint, nodes: nodes, truncated: false, effectiveDepth: nil, - viewport: .reported(box: viewport), - interfaceOrientation: RunnerInterfaceOrientation.portrait), + viewport: .reported( + box: viewport, + interfaceOrientation: RunnerInterfaceOrientation.portrait + )), options: PresentationOptions( interactiveOnly: interactiveOnly, depth: nil, scope: nil, raw: false), policy: .cursorProjected @@ -60,8 +62,7 @@ extension RunnerTests { ) -> [RawAXNode] { SnapshotGeometrySpace.normalized( nodes: privateAXAcquisition(rawRoot: rawRoot, hint: hint), - viewport: .reported(box: viewport), - interfaceOrientation: interfaceOrientation + viewport: .reported(box: viewport, interfaceOrientation: interfaceOrientation) ) } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift index edec6e8333..7f12cb87b6 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift @@ -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 diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift index 6af8e4e9e2..855abbd25b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift @@ -598,8 +598,10 @@ extension RunnerTests { ] let normalized = SnapshotGeometrySpace.normalized( nodes: acquired, - viewport: .reported(box: viewport), - interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + viewport: .reported( + box: viewport, + interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + ) ) let options = PresentationOptions(interactiveOnly: false, depth: 3, scope: nil, raw: false) let hint = SnapshotPresentation.captureHint(for: options) diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift index 13c321b545..300b8c910e 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift @@ -105,7 +105,7 @@ public enum CoordinateSpaceRotation { public enum SnapshotGeometrySpace: Equatable { case appOrientation - case deviceNative(appFrame: CGRect, interfaceOrientation: Int) + case deviceNative(appFrame: SnapshotViewport.Box, interfaceOrientation: Int) public static let quarterTurnTolerance: Double = 1 @@ -116,22 +116,26 @@ public enum SnapshotGeometrySpace: Equatable { case .deviceNative(let appFrame, let interfaceOrientation): return CoordinateSpaceRotation.oriented( rect: reportedFrame, - in: appFrame, + in: appFrame.rect, interfaceOrientation: interfaceOrientation ) } } + /// Only a `.reported` viewport carries an orientation, so only it can anchor a rotation. public static func space( reportedBySurfaceHost isSurfaceHost: Bool, reportedFrame: CGRect, inheritedFrom inherited: SnapshotGeometrySpace, - appFrame: CGRect, - interfaceOrientation: Int + viewport: SnapshotViewport ) -> SnapshotGeometrySpace { guard isSurfaceHost else { return inherited } - guard namesQuarterTurn(interfaceOrientation) else { return .appOrientation } - guard isQuarterTurned(reportedFrame, relativeTo: appFrame) else { return .appOrientation } + guard case .reported(let appFrame, let interfaceOrientation) = viewport, + namesQuarterTurn(interfaceOrientation), + isQuarterTurned(reportedFrame, relativeTo: appFrame.rect) + else { + return .appOrientation + } return .deviceNative(appFrame: appFrame, interfaceOrientation: interfaceOrientation) } @@ -145,9 +149,7 @@ public enum SnapshotGeometrySpace: Equatable { } private static func isQuarterTurned(_ frame: CGRect, relativeTo appFrame: CGRect) -> Bool { - // The quarter-turn test asks the same question the `hittable` predicate asks before it computes a - // center: is this a box that can be plotted at all. It is one predicate, not two that can drift. - guard SnapshotGeometry.isPositiveFinite(frame), SnapshotGeometry.isPositiveFinite(appFrame), + guard SnapshotGeometry.isPositiveFinite(frame), abs(appFrame.width - appFrame.height) > quarterTurnTolerance else { return false @@ -160,14 +162,9 @@ public enum SnapshotGeometrySpace: Equatable { extension SnapshotGeometrySpace { public static func normalized( nodes: [RawAXNode], - viewport: SnapshotViewport, - interfaceOrientation: Int + viewport: SnapshotViewport ) -> [RawAXNode] { let carriers = SnapshotVisibilityFold.visibilityExemptCarrierTypes - // No viewport box means no app frame to be quarter-turned relative to either. `.null` is the box - // `SnapshotGeometry.isPositiveFinite` refuses, so this pass turns nothing — the outcome - // `CGRect.infinite` produced before the fact carried the absence. - let appFrame = viewport.rect ?? .null var spaces = [SnapshotGeometrySpace](repeating: .appOrientation, count: nodes.count) var result: [RawAXNode] = [] result.reserveCapacity(nodes.count) @@ -180,16 +177,16 @@ extension SnapshotGeometrySpace { ), reportedFrame: node.rect.cgRect, inheritedFrom: parentIndex.map { spaces[$0] } ?? .appOrientation, - appFrame: appFrame, - interfaceOrientation: interfaceOrientation + viewport: viewport ) spaces[position] = nodeSpace let frame = nodeSpace.orientedFrame(of: node.rect.cgRect) result.append( node.replacing( rect: SnapshotRect(frame), - hittable: node.parentIndex != nil - && SnapshotGeometry.isGeometricallyActionable( + hittable: node.parentIndex == nil + ? false + : SnapshotGeometry.isGeometricallyActionable( enabled: node.enabled, frame: frame, viewport: viewport diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift index 6e6dfa59e3..d0db9a8e24 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift @@ -2,14 +2,8 @@ import Foundation import CoreGraphics public enum SnapshotGeometry { - /// The rect precondition of the shared `hittable` predicate and of anything that may serve as a - /// viewport box, mirroring `isPositiveFiniteRect` in `packages/kernel/src/rect.ts`. Swift previously - /// checked null/empty only, so two boxes the TypeScript side refuses were actionable here: a - /// non-canonical box — a negative width, which the private AX bridge's JSON frame parser can hand - /// over — and `CGRect.infinite`, whose center is (0,0) and therefore lands inside any viewport - /// (#2891). `isInfinite` is checked apart from the components because `CGRect.infinite` is made of - /// finite `Double`s (±DBL_MAX/2 and DBL_MAX); a box with actual infinite components is refused by - /// the component checks instead. + /// Twin of `isPositiveFiniteRect` in `packages/kernel/src/rect.ts`. `CGRect.infinite` is built + /// from finite components, so it is refused by identity. public static func isPositiveFinite(_ rect: CGRect) -> Bool { !rect.isInfinite && rect.origin.x.isFinite && rect.origin.y.isFinite @@ -17,11 +11,6 @@ public enum SnapshotGeometry { && rect.size.width > 0 && rect.size.height > 0 } - /// Clipping asks one question and containment asks another, so they read the fact separately. Here: - /// is there a box to clip against at all? A capture with no viewport cannot clip anything, which is - /// not the same claim as "no node is inside it" — that second one is - /// `isGeometricallyActionable`, and `SnapshotPresentationInvariant` relies on this pair by taking - /// the same `rect` (no box means no cumulative clip to violate, not an unbounded clip). public static func effectiveFrame( reportedFrame: CGRect, viewport: SnapshotViewport, @@ -54,35 +43,17 @@ public enum SnapshotGeometry { ) } - /// The one `hittable` predicate every iOS snapshot producer publishes (#1933), twin of - /// `isGeometricallyActionable` in `packages/kernel/src/rect.ts`, including `CGRect.contains`'s - /// half-open right and bottom edges: a center landing exactly on the viewport's right or bottom - /// edge is not hittable on either producer. Both languages first refuse a node rect that is not - /// positive and finite, so the precondition is one rule and not two (#2891). - /// - /// ## The unknown viewport — this site fails CLOSED - /// - /// `hittable` claims that a tap at the node's center lands. A capture with no viewport box cannot - /// support that claim, so `.missing` publishes no actionability at all. That is the host's own - /// direction with the instrument each side has: `resolveViewportEvidence` in - /// `packages/capture-kit/src/ios-snapshot-engine/invariants.ts` refuses to fold a regular - /// presentation without a positive finite viewport, so the TypeScript predicate is never reached - /// with an unknown viewport — its `viewport` parameter is total by construction, which is why it - /// has no case for the state. The runner's tree still presents: an empty interactive result is - /// visible to the caller and the plan can still reach a tier that resolves a box, while an - /// unsupported `true` is silent and would send a tap to a point nothing has located. - /// - /// A `.derived` box keeps answering containment. It is a screen box the capture read from its own - /// root element, and `hittable` is load-bearing downstream (the #2638 wrapper verdict reads a - /// declared `false` as evidence the wrapper is inert), so the fail-closed case stays exactly the - /// one case with no box. + /// The shared `hittable` predicate (#1933), twin of `isGeometricallyActionable` in + /// `packages/kernel/src/rect.ts`, with `CGRect.contains`'s half-open right and bottom edges. + /// `nil` when only containment is left to decide and the capture has no viewport box: the node's + /// `hittable` is then absent on the wire, as it is on the host bridge (#2891). public static func isGeometricallyActionable( enabled: Bool, frame: CGRect, viewport: SnapshotViewport - ) -> Bool { + ) -> Bool? { guard enabled, isPositiveFinite(frame) else { return false } - guard let box = viewport.rect else { return false } + guard let box = viewport.rect else { return nil } return box.contains(CGPoint(x: frame.midX, y: frame.midY)) } diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift index 10d439cb0d..d0421aab6d 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift @@ -38,7 +38,8 @@ public struct RawAXNode: Equatable { public let enabled: Bool public let focused: Bool? public let selected: Bool? - public var hittable: Bool + /// Geometric actionability; `nil` when the capture has no viewport box to decide it against. + public var hittable: Bool? public let depth: Int public let parentIndex: Int? public let hiddenContentAbove: Bool? @@ -55,7 +56,7 @@ public struct RawAXNode: Equatable { enabled: Bool, focused: Bool?, selected: Bool?, - hittable: Bool, + hittable: Bool?, depth: Int, parentIndex: Int?, hiddenContentAbove: Bool?, @@ -79,7 +80,7 @@ public struct RawAXNode: Equatable { self.actions = actions } - func replacing(rect: SnapshotRect, hittable: Bool) -> RawAXNode { + func replacing(rect: SnapshotRect, hittable: Bool?) -> RawAXNode { var updated = self updated.rect = rect updated.hittable = hittable @@ -153,15 +154,10 @@ public struct PresentationOptions: Equatable { } } -/// What a capture knows about the viewport hosting its tree, as the three-case fact the host's -/// `IosViewportEvidence` already uses (#2891). A rectangle is never allowed to stand for "unknown": -/// `CGRect.infinite` crossing this boundary read as "everything is actionable" on the runner and as -/// "publish nothing" on the host, which is the same state resolved in two directions. +/// What a capture knows about the viewport hosting its tree: the three cases of the host's +/// `IosViewportEvidence` (#2891). No rectangle stands for "unknown". public enum SnapshotViewport: Equatable { - /// A box that `SnapshotGeometry.isPositiveFinite` has already accepted. The initialiser is internal, - /// which is what keeps this an enumerated fact instead of a checked suggestion: outside this package - /// the only way to put a box inside a viewport fact is `reported(box:)` or `derived(box:)` below, so - /// no caller can name a case around them and hand over the sentinel again (#2891). + /// A box `SnapshotGeometry.isPositiveFinite` accepted. Only the factories below construct one. public struct Box: Equatable { public let rect: CGRect @@ -170,45 +166,43 @@ public enum SnapshotViewport: Equatable { } } - /// The platform's own box for the app's surface. - case reported(Box) - /// A box the capture inferred for itself out of its own root element instead of a screen read. It - /// clips and contains like a reported box, and it never anchors a rotation: the tier that produces - /// it reports no interface orientation beside it (#2612). + /// The platform's box for the app's surface, with the interface orientation read in the same hop. + /// Only this case can anchor a rotation (#2612). + case reported(Box, interfaceOrientation: Int) + /// A box the capture inferred from its own root element. It clips and contains, and carries no + /// orientation, so it cannot anchor a rotation. case derived(Box) - /// No box. See `SnapshotGeometry.isGeometricallyActionable` for the one policy this answers. case missing(reason: MissingReason) public enum MissingReason: Equatable { - /// Nothing was read: the read was skipped, or it raised. + /// The read was skipped or raised. case notProvided - /// A box arrived that cannot be a viewport: null, empty, inverted, or non-finite, which is what - /// `SnapshotGeometry.isPositiveFinite` refuses. + /// The box read is one `SnapshotGeometry.isPositiveFinite` refuses. case invalid } - /// The box to compare geometry against, or `nil` when the capture has none. Nothing that needs a - /// box may substitute an unbounded one for the absence of one. public var rect: CGRect? { switch self { - case .reported(let box), .derived(let box): + case .reported(let box, _), .derived(let box): return box.rect case .missing: return nil } } - /// Declares the box the platform reported for the app's surface. A box that cannot be a viewport - /// becomes `.missing(reason: .invalid)` here, at the one place a box becomes a viewport, so no - /// consumer has to re-check what it was handed. - public static func reported(box: CGRect) -> SnapshotViewport { - SnapshotGeometry.isPositiveFinite(box) ? .reported(Box(positiveFinite: box)) : .missing(reason: .invalid) + public static func reported( + box: CGRect, + interfaceOrientation: Int = RunnerInterfaceOrientation.unknown + ) -> SnapshotViewport { + SnapshotGeometry.isPositiveFinite(box) + ? .reported(Box(positiveFinite: box), interfaceOrientation: interfaceOrientation) + : .missing(reason: .invalid) } - /// Declares the capture's own root box as its viewport. Same refusal as `reported(box:)`: an - /// unusable root box is no box at all. public static func derived(box: CGRect) -> SnapshotViewport { - SnapshotGeometry.isPositiveFinite(box) ? .derived(Box(positiveFinite: box)) : .missing(reason: .invalid) + SnapshotGeometry.isPositiveFinite(box) + ? .derived(Box(positiveFinite: box)) + : .missing(reason: .invalid) } } @@ -219,8 +213,6 @@ public struct SnapshotAcquisition { public let effectiveDepth: Int? public var customActions: SnapshotCustomActionCoverage? public let viewport: SnapshotViewport - /// The app's interface orientation, consumed by the one `normalized` pass; `unknown` turns nothing. - public let interfaceOrientation: Int public init( hint: CaptureHint, @@ -228,8 +220,7 @@ public struct SnapshotAcquisition { truncated: Bool, effectiveDepth: Int?, customActions: SnapshotCustomActionCoverage? = nil, - viewport: SnapshotViewport, - interfaceOrientation: Int = 0 + viewport: SnapshotViewport ) { self.hint = hint self.nodes = nodes @@ -237,7 +228,6 @@ public struct SnapshotAcquisition { self.effectiveDepth = effectiveDepth self.customActions = customActions self.viewport = viewport - self.interfaceOrientation = interfaceOrientation } public func replacingNodes(_ nodes: [RawAXNode]) -> SnapshotAcquisition { @@ -285,7 +275,7 @@ public struct PresentedNode: Codable, Equatable { public let enabled: Bool public let focused: Bool? public let selected: Bool? - public let hittable: Bool + public let hittable: Bool? public let depth: Int public let parentIndex: Int? public let hiddenContentAbove: Bool? diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationInvariant.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationInvariant.swift index 5390d79c09..a28ba33b2c 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationInvariant.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationInvariant.swift @@ -1,14 +1,7 @@ import Foundation import CoreGraphics -/// What `SnapshotPresentation` guarantees about the regular projection it just folded. -/// -/// The viewport enters exactly once, as the root of the cumulative ancestor clip. When the capture -/// has no viewport box the clip has no root, so containment has nothing to violate — that is the -/// absence of an answer, not an unbounded clip, and it is why `SnapshotViewport.rect` is `nil` here -/// rather than a box that swallows every check. The degenerate-node rule below does not consult the -/// viewport at all, so a capture that cannot locate the screen still cannot present a null or empty -/// frame as actionable (#2638 reads that verdict as evidence a wrapper is inert). +/// With no viewport box the cumulative clip has no root, so containment has nothing to violate. public enum SnapshotPresentationInvariant { struct ValidationStats: Equatable { let parentClipLookups: Int @@ -59,7 +52,7 @@ public enum SnapshotPresentationInvariant { clipIncludingNodeByIndex[node.raw.index] = clipIncludingNode guard !frame.isNull, !frame.isEmpty else { - if node.raw.hittable { + if node.raw.hittable == true { throw SnapshotPresentationFailure.regularDegenerateNodeIsActionable( index: node.raw.index, frame: node.effectiveRect diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift index d304ca55a6..9f9a4d3ea3 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift @@ -203,12 +203,17 @@ public enum SnapshotVisibilityFold { enabled: node.enabled, focused: node.focused, selected: node.selected, - hittable: node.parentIndex != nil && node.hittable - && SnapshotGeometry.isGeometricallyActionable( - enabled: node.enabled, - frame: decision.effectiveFrame, - viewport: viewport - ), + hittable: node.parentIndex == nil + ? false + : node.hittable.flatMap { sourceHittable in + sourceHittable + ? SnapshotGeometry.isGeometricallyActionable( + enabled: node.enabled, + frame: decision.effectiveFrame, + viewport: viewport + ) + : false + }, depth: outDepth, parentIndex: keptIndex, hiddenContentAbove: node.hiddenContentAbove, diff --git a/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift b/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift index ef4b796b84..005386ba10 100644 --- a/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift +++ b/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift @@ -75,10 +75,7 @@ private func acquisition(for input: ConformanceInput) -> SnapshotAcquisition { }, truncated: false, effectiveDepth: nil, - // The differential always reports a viewport. Without one the host engine's regular presentation - // throws `missing-viewport` before folding (`invariants.ts`), so an unknown viewport has no - // TypeScript outcome to compare a fold against; the predicate's unknown-viewport rows belong to - // contracts/fixtures/snapshot-actionability-policy.json instead (#2891). + // The host engine refuses to fold without a viewport, so the differential always reports one. viewport: .reported(box: input.viewport.cgRect) ) } diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift index 971d8ed0d8..27882e1442 100644 --- a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift @@ -3,55 +3,13 @@ import CoreGraphics import Foundation import XCTest -/// Golden vector table for the shared `hittable` predicate (#2891). The same -/// `contracts/fixtures/snapshot-actionability-policy.json` rows are replayed against the TypeScript -/// twin (`isGeometricallyActionable` in `packages/kernel/src/rect.ts`) by -/// `scripts/ios-snapshot-differential.test.ts`, so drift between the runner's Swift rule and the -/// host's reads red on whichever side moved. +/// Replays `contracts/fixtures/snapshot-actionability-policy.json` against the Swift predicate. The +/// TypeScript twin replays the same rows in `scripts/ios-snapshot-differential.test.ts`. final class ActionabilityPolicyTests: XCTestCase { private struct Table: Decodable { - let description: String let cases: [PolicyCase] } - /// A rect as JSON can carry one. Infinity has no JSON spelling, so the two unusable boxes a platform - /// can hand back are named: `{"infinite": true}` is `CGRect.infinite`, the box Apple returns for - /// "resolved none" (`window-coordinate-space.json` spells it the same), and `{"nonFinite": true}` is - /// a box with actual infinite components, which is what the host's frame decoder refuses. - private struct RectBox: Decodable { - private enum CodingKeys: String, CodingKey { - case x, y, width, height, infinite, nonFinite - } - - let cgRect: CGRect - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - guard try container.decodeIfPresent(Bool.self, forKey: .infinite) != true else { - self.cgRect = .infinite - return - } - guard try container.decodeIfPresent(Bool.self, forKey: .nonFinite) != true else { - self.cgRect = CGRect( - x: -.infinity, - y: -.infinity, - width: .infinity, - height: .infinity - ) - return - } - self.cgRect = CGRect( - x: try container.decode(Double.self, forKey: .x), - y: try container.decode(Double.self, forKey: .y), - width: try container.decode(Double.self, forKey: .width), - height: try container.decode(Double.self, forKey: .height) - ) - } - } - - /// The viewport as the three-case fact it crosses the boundary as. A row declaring `reported` or - /// `derived` has to survive the declaration factories with that kind intact, so the table cannot - /// quietly start exercising the `missing` policy under a reported label. private struct ViewportFact: Decodable { private enum CodingKeys: String, CodingKey { case kind, rect, reason @@ -60,8 +18,7 @@ final class ActionabilityPolicyTests: XCTestCase { let declaredKind: String let viewport: SnapshotViewport - /// A row labelled `reported` whose box cannot be a viewport would silently start testing the - /// `missing` policy, so the declaration has to come back with the kind the row names. + /// A row whose box the factory refuses would silently test `missing` under another label. var matchesDeclaredKind: Bool { switch (declaredKind, viewport) { case ("reported", .reported), ("derived", .derived), ("missing", .missing): @@ -77,9 +34,9 @@ final class ActionabilityPolicyTests: XCTestCase { self.declaredKind = kind switch kind { case "reported": - self.viewport = .reported(box: try container.decode(RectBox.self, forKey: .rect).cgRect) + self.viewport = .reported(box: try container.decode(FixtureRect.self, forKey: .rect).cgRect) case "derived": - self.viewport = .derived(box: try container.decode(RectBox.self, forKey: .rect).cgRect) + self.viewport = .derived(box: try container.decode(FixtureRect.self, forKey: .rect).cgRect) case "missing": switch try container.decode(String.self, forKey: .reason) { case "not-provided": @@ -104,42 +61,40 @@ final class ActionabilityPolicyTests: XCTestCase { } private struct PolicyCase: Decodable { - private enum CodingKeys: String, CodingKey { - case name, swift, typescript, asymmetry, enabled, node, viewport, hittable, nodeRectGuardPasses - } - let name: String let swift: Bool let typescript: Bool let asymmetry: String? let enabled: Bool - let node: RectBox + let node: FixtureRect let viewport: ViewportFact - let hittable: Bool + /// `nil` is the absent bit. + let hittable: Bool? let nodeRectGuardPasses: Bool - /// A row one language skips is a written-down divergence, and a divergence without a reason is how - /// two implementations start disagreeing quietly again: a shared row carries no reason and a skipped - /// row carries exactly one. var declaresItsAsymmetry: Bool { (swift && typescript) != (asymmetry?.isEmpty == false) } } func testActionabilityPolicyAgreesWithEveryGoldenVector() throws { - let table = try loadActionabilityPolicyTable() - XCTAssertFalse(table.cases.isEmpty, "vector table must not be empty") + let table = try JSONDecoder().decode( + Table.self, + from: Data(contentsOf: contractsFixtureURL("snapshot-actionability-policy.json")) + ) + XCTAssertEqual(Set(table.cases.map(\.name)).count, table.cases.count, "names must be unique") + let swiftCases = table.cases.filter(\.swift) XCTAssertEqual( - Set(table.cases.map(\.name)).count, - table.cases.count, - "vector names must be unique" + Set(swiftCases.map(\.viewport.declaredKind)), + ["reported", "derived", "missing"] ) for testCase in table.cases { XCTAssertTrue( testCase.declaresItsAsymmetry, "\(testCase.name): a row both languages do not share must name the asymmetry" ) - guard testCase.swift else { continue } + } + for testCase in swiftCases { XCTAssertTrue( testCase.viewport.matchesDeclaredKind, "\(testCase.name): declared \(testCase.viewport.declaredKind) must survive declaration" @@ -160,31 +115,4 @@ final class ActionabilityPolicyTests: XCTestCase { ) } } - - /// The table cannot be trimmed until the unknown-viewport policy is the only thing left untested: - /// every declared kind has to be present, and no `missing` row may rest on a node rect that the - /// guard already refuses — that would make the row's `false` say nothing about the policy. - func testActionabilityPolicyCoversEveryViewportKindWithoutAVacuousMissingRow() throws { - let cases = try loadActionabilityPolicyTable().cases.filter(\.swift) - XCTAssertEqual(Set(cases.map(\.viewport.declaredKind)), ["reported", "derived", "missing"]) - for testCase in cases where testCase.viewport.declaredKind == "missing" { - XCTAssertTrue( - testCase.nodeRectGuardPasses, - "\(testCase.name): a missing-viewport row must have a node the guard accepts" - ) - } - } - - private func loadActionabilityPolicyTable() throws -> Table { - let tableURL = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() // AgentDeviceSnapshotPresentationTests - .deletingLastPathComponent() // Tests - .deletingLastPathComponent() // snapshot-presentation - .deletingLastPathComponent() // apple - .deletingLastPathComponent() // repo root - .appendingPathComponent("contracts") - .appendingPathComponent("fixtures") - .appendingPathComponent("snapshot-actionability-policy.json") - return try JSONDecoder().decode(Table.self, from: Data(contentsOf: tableURL)) - } } diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/CoordinateSpaceTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/CoordinateSpaceTests.swift index 3d968d8e53..c5df2bc22c 100644 --- a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/CoordinateSpaceTests.swift +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/CoordinateSpaceTests.swift @@ -7,30 +7,7 @@ import XCTest /// without a simulator, and the runner's walkers are tested separately for threading it through. final class CoordinateSpaceTests: XCTestCase { private struct WindowCoordinateSpaceFixture: Decodable { - /// A frame as JSON can carry one. Infinity has no JSON spelling, so the unusable box a platform - /// hands back is named `{"infinite": true}`: `CGRect.infinite` here, and an infinite rect in the - /// vitest twin. - struct Frame: Decodable { - private enum CodingKeys: String, CodingKey { - case x, y, width, height, infinite - } - - let cgRect: CGRect - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - guard try container.decodeIfPresent(Bool.self, forKey: .infinite) != true else { - self.cgRect = .infinite - return - } - self.cgRect = CGRect( - x: try container.decode(Double.self, forKey: .x), - y: try container.decode(Double.self, forKey: .y), - width: try container.decode(Double.self, forKey: .width), - height: try container.decode(Double.self, forKey: .height) - ) - } - } + typealias Frame = FixtureRect struct Constants: Decodable { let quarterTurnTolerance: Double @@ -161,10 +138,9 @@ final class CoordinateSpaceTests: XCTestCase { reportedBySurfaceHost: true, reportedFrame: CGRect(x: 0, y: 0, width: 402, height: 874), inheritedFrom: .appOrientation, - appFrame: appFrame, - interfaceOrientation: landscape + viewport: .reported(box: appFrame, interfaceOrientation: landscape) ) - XCTAssertEqual(space, .deviceNative(appFrame: appFrame, interfaceOrientation: landscape)) + XCTAssertEqual(space, deviceNative(appFrame, landscape)) // Reported on iPhone 17 Pro (iOS 26.2), landscape, system keyboard over the fixture's form. XCTAssertEqual( @@ -198,8 +174,10 @@ final class CoordinateSpaceTests: XCTestCase { reportedBySurfaceHost: true, reportedFrame: rotated, inheritedFrom: .appOrientation, - appFrame: appFrame, - interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + viewport: .reported( + box: appFrame, + interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + ) ) // The app's own window reports the app's box, and so does a hosted surface that already @@ -209,8 +187,10 @@ final class CoordinateSpaceTests: XCTestCase { reportedBySurfaceHost: true, reportedFrame: appFrame, inheritedFrom: nativeSpace, - appFrame: appFrame, - interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + viewport: .reported( + box: appFrame, + interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + ) ), .appOrientation ) @@ -223,8 +203,10 @@ final class CoordinateSpaceTests: XCTestCase { ), reportedFrame: CGRect(x: 154, y: 77, width: 45, height: 72), inheritedFrom: nativeSpace, - appFrame: appFrame, - interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + viewport: .reported( + box: appFrame, + interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + ) ), nativeSpace ) @@ -234,8 +216,7 @@ final class CoordinateSpaceTests: XCTestCase { reportedBySurfaceHost: true, reportedFrame: rotated, inheritedFrom: .appOrientation, - appFrame: appFrame, - interfaceOrientation: RunnerInterfaceOrientation.unknown + viewport: .reported(box: appFrame, interfaceOrientation: RunnerInterfaceOrientation.unknown) ) XCTAssertEqual(unnamed, .appOrientation) XCTAssertEqual( @@ -248,23 +229,26 @@ final class CoordinateSpaceTests: XCTestCase { reportedBySurfaceHost: true, reportedFrame: rotated, inheritedFrom: .appOrientation, - appFrame: CGRect(x: 16, y: 24, width: 874, height: 402), - interfaceOrientation: RunnerInterfaceOrientation.portrait - ), - .appOrientation - ) - // An app frame the capture could not resolve cannot anchor a rotation. `.null` is what the one - // normalization pass hands over for a capture whose viewport fact is `missing` (#2891). - XCTAssertEqual( - SnapshotGeometrySpace.space( - reportedBySurfaceHost: true, - reportedFrame: rotated, - inheritedFrom: .appOrientation, - appFrame: .null, - interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + viewport: .reported( + box: CGRect(x: 16, y: 24, width: 874, height: 402), + interfaceOrientation: RunnerInterfaceOrientation.portrait + ) ), .appOrientation ) + // Only a reported viewport carries an orientation: no box, or a box the capture derived from + // its own root, cannot anchor a rotation (#2891). + for viewport in [SnapshotViewport.missing(reason: .notProvided), .derived(box: appFrame)] { + XCTAssertEqual( + SnapshotGeometrySpace.space( + reportedBySurfaceHost: true, + reportedFrame: rotated, + inheritedFrom: .appOrientation, + viewport: viewport + ), + .appOrientation + ) + } // A square app cannot be told from its own quarter turn, so its geometry is left alone. let square = CGRect(x: 0, y: 0, width: 800, height: 800) XCTAssertEqual( @@ -272,8 +256,10 @@ final class CoordinateSpaceTests: XCTestCase { reportedBySurfaceHost: true, reportedFrame: square, inheritedFrom: .appOrientation, - appFrame: square, - interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + viewport: .reported( + box: square, + interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + ) ), .appOrientation ) @@ -294,18 +280,16 @@ final class CoordinateSpaceTests: XCTestCase { reportedBySurfaceHost: true, reportedFrame: appFrame, inheritedFrom: .appOrientation, - appFrame: appFrame, - interfaceOrientation: landscape + viewport: .reported(box: appFrame, interfaceOrientation: landscape) ) XCTAssertEqual(windowSpace, .appOrientation) let surfaceSpace = SnapshotGeometrySpace.space( reportedBySurfaceHost: true, reportedFrame: turned, inheritedFrom: windowSpace, - appFrame: appFrame, - interfaceOrientation: landscape + viewport: .reported(box: appFrame, interfaceOrientation: landscape) ) - XCTAssertEqual(surfaceSpace, .deviceNative(appFrame: appFrame, interfaceOrientation: landscape)) + XCTAssertEqual(surfaceSpace, deviceNative(appFrame, landscape)) // Deep in the tree a turned box is content reporting large bounds, not a hosted surface: it keeps // the space it inherited rather than rewriting the space below it. XCTAssertEqual( @@ -313,8 +297,7 @@ final class CoordinateSpaceTests: XCTestCase { reportedBySurfaceHost: false, reportedFrame: turned, inheritedFrom: windowSpace, - appFrame: appFrame, - interfaceOrientation: landscape + viewport: .reported(box: appFrame, interfaceOrientation: landscape) ), .appOrientation ) @@ -341,15 +324,14 @@ final class CoordinateSpaceTests: XCTestCase { RunnerInterfaceOrientation.landscapeRight, RunnerInterfaceOrientation.landscapeLeft ] { let expected: SnapshotGeometrySpace = testCase.quarterTurned - ? .deviceNative(appFrame: appFrame, interfaceOrientation: interfaceOrientation) + ? deviceNative(appFrame, interfaceOrientation) : .appOrientation XCTAssertEqual( SnapshotGeometrySpace.space( reportedBySurfaceHost: true, reportedFrame: testCase.window.cgRect, inheritedFrom: .appOrientation, - appFrame: appFrame, - interfaceOrientation: interfaceOrientation + viewport: .reported(box: appFrame, interfaceOrientation: interfaceOrientation) ), expected, "\(testCase.name) (interfaceOrientation \(interfaceOrientation))" @@ -369,16 +351,15 @@ final class CoordinateSpaceTests: XCTestCase { } } + private func deviceNative(_ appFrame: CGRect, _ interfaceOrientation: Int) -> SnapshotGeometrySpace { + guard case .reported(let box, _) = SnapshotViewport.reported(box: appFrame) else { + preconditionFailure("\(appFrame) is not a viewport box") + } + return .deviceNative(appFrame: box, interfaceOrientation: interfaceOrientation) + } + private func loadWindowCoordinateSpaceFixture() throws -> WindowCoordinateSpaceFixture { - let fixtureURL = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() // AgentDeviceSnapshotPresentationTests - .deletingLastPathComponent() // Tests - .deletingLastPathComponent() // snapshot-presentation - .deletingLastPathComponent() // apple - .deletingLastPathComponent() // repo root - .appendingPathComponent("contracts") - .appendingPathComponent("fixtures") - .appendingPathComponent("window-coordinate-space.json") + let fixtureURL = contractsFixtureURL("window-coordinate-space.json") return try JSONDecoder().decode( WindowCoordinateSpaceFixture.self, from: Data(contentsOf: fixtureURL) @@ -422,8 +403,7 @@ final class CoordinateSpaceTests: XCTestCase { let expected = (namesQuarterTurn && !squareApp) ? testCase.oriented : testCase.native let normalized = SnapshotGeometrySpace.normalized( nodes: turnedSubtree(app: app, reportedLeaf: testCase.native.cgRect), - viewport: .reported(box: app), - interfaceOrientation: testCase.interfaceOrientation + viewport: .reported(box: app, interfaceOrientation: testCase.interfaceOrientation) ) XCTAssertEqual(normalized.count, 4) XCTAssertEqual(normalized[3].rect.cgRect, expected.cgRect, testCase.name) @@ -444,8 +424,7 @@ final class CoordinateSpaceTests: XCTestCase { ] let normalized = SnapshotGeometrySpace.normalized( nodes: acquired, - viewport: .reported(box: app), - interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + viewport: .reported(box: app, interfaceOrientation: RunnerInterfaceOrientation.landscapeRight) ) XCTAssertEqual( normalized.first { $0.label == "planeBand" }?.rect, @@ -471,8 +450,7 @@ final class CoordinateSpaceTests: XCTestCase { ] let normalized = SnapshotGeometrySpace.normalized( nodes: acquired, - viewport: .reported(box: app), - interfaceOrientation: RunnerInterfaceOrientation.unknown + viewport: .reported(box: app, interfaceOrientation: RunnerInterfaceOrientation.unknown) ) XCTAssertEqual(normalized.map(\.rect), acquired.map(\.rect)) XCTAssertEqual(normalized[1].hittable, true) @@ -480,12 +458,42 @@ final class CoordinateSpaceTests: XCTestCase { XCTAssertEqual(normalized[0].hittable, false) } + /// Without a viewport box the pass turns nothing and cannot decide containment, so a child's + /// `hittable` is absent rather than declared; a disabled child is still declared `false` (#2891). + func testNormalizedWithoutAViewportLeavesContainmentUndecided() { + let app = CGRect(x: 0, y: 0, width: 874, height: 402) + let acquired = turnedSubtree(app: app, reportedLeaf: CGRect(x: 100, y: 100, width: 40, height: 20)) + let normalized = SnapshotGeometrySpace.normalized( + nodes: acquired, + viewport: .missing(reason: .notProvided) + ) + XCTAssertEqual(normalized.map(\.rect), acquired.map(\.rect)) + XCTAssertEqual(normalized[0].hittable, false) + XCTAssertNil(normalized[3].hittable) + + let disabled = RawAXNode( + index: 1, type: "Button", label: nil, identifier: nil, value: nil, + rect: SnapshotRect(x: 100, y: 100, width: 40, height: 20), + enabled: false, focused: nil, selected: nil, hittable: true, + depth: 1, parentIndex: 0, hiddenContentAbove: nil, hiddenContentBelow: nil + ) + XCTAssertEqual( + SnapshotGeometrySpace.normalized( + nodes: [acquired[0], disabled], + viewport: .missing(reason: .notProvided) + )[1].hittable, + false + ) + } + func testNormalizedOfAnEmptyArrayIsEmpty() { XCTAssertEqual( SnapshotGeometrySpace.normalized( nodes: [], - viewport: .reported(box: CGRect(x: 0, y: 0, width: 874, height: 402)), - interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + viewport: .reported( + box: CGRect(x: 0, y: 0, width: 874, height: 402), + interfaceOrientation: RunnerInterfaceOrientation.landscapeRight + ) ), [] ) diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/FixtureRect.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/FixtureRect.swift new file mode 100644 index 0000000000..f3152d43b4 --- /dev/null +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/FixtureRect.swift @@ -0,0 +1,41 @@ +import CoreGraphics +import Foundation + +/// A rect as a `contracts/fixtures/` table spells it. JSON has no infinity, so the two unusable +/// boxes are named: `{"infinite": true}` is `CGRect.infinite`, and `{"nonFinite": true}` is a +/// box whose components are infinite. +struct FixtureRect: Decodable { + private enum CodingKeys: String, CodingKey { + case x, y, width, height, infinite, nonFinite + } + + let cgRect: CGRect + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if try container.decodeIfPresent(Bool.self, forKey: .infinite) == true { + self.cgRect = .infinite + } else if try container.decodeIfPresent(Bool.self, forKey: .nonFinite) == true { + self.cgRect = CGRect(x: -.infinity, y: -.infinity, width: .infinity, height: .infinity) + } else { + self.cgRect = CGRect( + x: try container.decode(Double.self, forKey: .x), + y: try container.decode(Double.self, forKey: .y), + width: try container.decode(Double.self, forKey: .width), + height: try container.decode(Double.self, forKey: .height) + ) + } + } +} + +func contractsFixtureURL(_ name: String, from filePath: String = #filePath) -> URL { + URL(fileURLWithPath: filePath) + .deletingLastPathComponent() // AgentDeviceSnapshotPresentationTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // snapshot-presentation + .deletingLastPathComponent() // apple + .deletingLastPathComponent() // repo root + .appendingPathComponent("contracts") + .appendingPathComponent("fixtures") + .appendingPathComponent(name) +} diff --git a/contracts/fixtures/snapshot-actionability-policy.json b/contracts/fixtures/snapshot-actionability-policy.json index 5055ddf606..5a567f6495 100644 --- a/contracts/fixtures/snapshot-actionability-policy.json +++ b/contracts/fixtures/snapshot-actionability-policy.json @@ -1,5 +1,5 @@ { - "description": "Golden vector table for the shared `hittable` predicate (#1933), pinned for the input shapes the fixed 320x240 fold fixture in ios-snapshot-engine-conformance.json cannot reach (#2891). RULE: a node is actionable when it is enabled, its own rect is positive and finite, and its center falls inside the viewport box, half-open on the right and bottom edges (inclusive on the left and top). Two implementations: isGeometricallyActionable in apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift (replayed by ActionabilityPolicyTests, and reached on device through SnapshotGeometrySpace.normalized and SnapshotVisibilityFold.fold) and isGeometricallyActionable in packages/kernel/src/rect.ts (replayed by scripts/ios-snapshot-differential.test.ts). EVERY ROW declares both `swift` and `typescript`; a row either side skips must carry `asymmetry` naming why, so a divergence is always written down rather than discovered. THE VIEWPORT IS A DECLARED FACT, not a rectangle: `reported` is the platform's box for the app's surface, `derived` is a box the capture inferred for itself out of its own root element, and `missing` carries `not-provided` (nothing was read) or `invalid` (a box arrived that cannot be one). These are the three cases of IosViewportEvidence in packages/contracts/src/ios-snapshot.ts, and the old spelling - CGRect.infinite as the viewport - made every node actionable on the runner while the host published nothing, which is one state resolved in two directions. POLICY FOR AN UNKNOWN VIEWPORT, stated once at the Swift decision site and failing CLOSED: `missing` publishes no actionability, because `hittable` claims a tap at the center lands and a capture with no box cannot support that claim. The bit is published, never filtered: SnapshotVisibilityFoldProjection.shouldInclude and isEligibleForRegularPresentation retain a visible node whatever its bit says, so a retained node carries `hittable: false` and a caller or a `hittable:` selector reads it, while a wrong `true` is a claim nothing contradicts. The host reaches the same direction earlier in its own capture path, where resolveViewportEvidence in packages/capture-kit/src/ios-snapshot-engine/invariants.ts refuses to fold a regular presentation without a positive finite viewport - which is why the TypeScript predicate has no unknown-viewport case, and why this state cannot be compared through the fold differential.", + "description": "Golden vector table for the shared `hittable` predicate (#1933, #2891), for input shapes the fixed 320x240 fold fixture in ios-snapshot-engine-conformance.json cannot reach. RULE: a node is actionable when it is enabled, its own rect is positive and finite, and its center falls inside the viewport box, half-open on the right and bottom edges. Implementations: SnapshotGeometry.isGeometricallyActionable (Swift, replayed by ActionabilityPolicyTests) and isGeometricallyActionable in packages/kernel/src/rect.ts (replayed by scripts/ios-snapshot-differential.test.ts). The viewport is the declared fact `reported`, `derived`, or `missing` (IosViewportEvidence). `hittable: null` is the absent bit: with no viewport box, containment is undecided. Every row declares `swift` and `typescript`; a row one side skips carries `asymmetry`.", "cases": [ { "name": "a center strictly inside the reported box is actionable", @@ -153,25 +153,49 @@ "nodeRectGuardPasses": true }, { - "name": "the same node whose center sits inside a reported box is not actionable once the viewport goes missing: the policy fails closed", + "name": "the same node whose center sits inside a reported box has no hittable bit once the viewport goes missing", "swift": true, - "typescript": true, + "typescript": false, + "asymmetry": "The TypeScript predicate takes a box. Without one, each host path withholds the bit before asking it: the bridge reader publishes derived hittability only for a reported viewport, and resolveViewportEvidence refuses to fold a regular presentation.", "enabled": true, "node": { "x": 100, "y": 100, "width": 40, "height": 20 }, "viewport": { "kind": "missing", "reason": "not-provided" }, - "hittable": false, + "hittable": null, "nodeRectGuardPasses": true }, { - "name": "a viewport declared invalid because a box arrived that cannot be one fails closed the same way", + "name": "a viewport declared invalid leaves the bit absent the same way", "swift": true, - "typescript": true, + "typescript": false, + "asymmetry": "The TypeScript predicate takes a box. Without one, each host path withholds the bit before asking it: the bridge reader publishes derived hittability only for a reported viewport, and resolveViewportEvidence refuses to fold a regular presentation.", "enabled": true, "node": { "x": 100, "y": 100, "width": 40, "height": 20 }, "viewport": { "kind": "missing", "reason": "invalid" }, + "hittable": null, + "nodeRectGuardPasses": true + }, + { + "name": "a disabled node is not actionable without a viewport: that half of the rule needs no box", + "swift": true, + "typescript": false, + "asymmetry": "The TypeScript predicate takes a box. Without one, each host path withholds the bit before asking it: the bridge reader publishes derived hittability only for a reported viewport, and resolveViewportEvidence refuses to fold a regular presentation.", + "enabled": false, + "node": { "x": 100, "y": 100, "width": 40, "height": 20 }, + "viewport": { "kind": "missing", "reason": "not-provided" }, "hittable": false, "nodeRectGuardPasses": true }, + { + "name": "a zero-area node is not actionable without a viewport: the node guard needs no box", + "swift": true, + "typescript": false, + "asymmetry": "The TypeScript predicate takes a box. Without one, each host path withholds the bit before asking it: the bridge reader publishes derived hittability only for a reported viewport, and resolveViewportEvidence refuses to fold a regular presentation.", + "enabled": true, + "node": { "x": 100, "y": 100, "width": 0, "height": 20 }, + "viewport": { "kind": "missing", "reason": "not-provided" }, + "hittable": false, + "nodeRectGuardPasses": false + }, { "name": "a derived box the capture read from its own root still answers containment", "swift": true, diff --git a/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index 4ee1a3f8af..9610cc8bb9 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -304,21 +304,15 @@ a typed `IOS_SNAPSHOT_PRESENTATION_FAILED` capture failure with the named `prese snapshot-quality reason, preserved through recovery and the existing TypeScript verdict/warning contract. -Inside the runner the viewport is a declared fact and not a rectangle: `SnapshotViewport` carries -`reported`, `derived`, or `missing { reason }`, the three cases `IosViewportEvidence` already uses on -the host. The runner previously spelled "unknown" as `CGRect.infinite`, and that one state resolved in -opposite directions — every node actionable on the runner, none published by the host's own derived -hittability (#2891). One policy, stated at the Swift decision site, fails CLOSED: with no viewport box -no node is actionable, while the clip skips and the cumulative-clip invariant is left with no root -clip to violate rather than an unbounded one. The fact decides only what the runner publishes: a -capture crosses into the host as nodes alone (`makeSnapshotBackendCapture`), and there the host forms -its own evidence from the capture's root node (`viewportFromRoot`), which is `reported` whenever that -root box is positive and finite. In the host's own capture path the same direction is reached earlier -and harder, because `resolveViewportEvidence` refuses to fold a regular presentation at all — which is -why the TypeScript predicate has no unknown-viewport case and why this state cannot be compared -through the fold differential. `contracts/fixtures/snapshot-actionability-policy.json` pins the -predicate for the shapes the fixed 320x240 fold fixture cannot reach: the half-open right and bottom -edges, a node rect neither language's null/empty check refuses, and an unknown viewport. +Inside the runner the viewport is a declared fact, not a rectangle: `SnapshotViewport` is +`reported(box, interfaceOrientation)`, `derived(box)`, or `missing(reason)`, the cases of the host's +`IosViewportEvidence` (#2891). Only `reported` carries an orientation, so only it can anchor a +rotation in `SnapshotGeometrySpace`. With no box the clip skips, the cumulative-clip invariant has no +root clip to violate, and a node whose actionability depends on containment has no `hittable` on the +wire, as on the host bridge; disabled or degenerate nodes stay declared `false`. The runner route's +host evidence comes from the payload's root nodes (`resolveIosViewportEvidenceFromRoots` in +`packages/capture-kit/src/ios-snapshot-acquisition.ts`). `contracts/fixtures/snapshot-actionability-policy.json` +pins the predicate for shapes the 320x240 fold fixture cannot reach. A regular `--depth` request is a presentation cut, not an acquisition bound. `CaptureHint` keeps raw traversal depth (`--raw --depth`) separate from regular presented depth, but the recursive tree walk diff --git a/packages/kernel/src/rect.ts b/packages/kernel/src/rect.ts index 44d70405f8..a6ebb19bc5 100644 --- a/packages/kernel/src/rect.ts +++ b/packages/kernel/src/rect.ts @@ -1,15 +1,6 @@ import type { Rect } from './snapshot.ts'; -/** - * The rect precondition shared by every iOS snapshot producer's `hittable` claim, and the twin of - * `SnapshotGeometry.isPositiveFinite` on the runner. - * - * It is a rule about numbers, not about values one platform invents. Apple's "resolved none" box, - * `CGRect.infinite`, is built out of finite `Double`s, so no arithmetic here can recognise it; the - * Swift side refuses it by identity because that side can name it. A frame with components that are - * genuinely not finite is refused further upstream, by `frameFromGuest` in - * `packages/platform-apple/src/snapshot-source/tree.ts`, before any predicate is asked (#2891). - */ +/** Twin of `SnapshotGeometry.isPositiveFinite` on the runner (#2891). */ export function isPositiveFiniteRect(rect: Rect | undefined): rect is Rect { return Boolean( rect && @@ -41,24 +32,10 @@ export function containsPoint(rect: Rect, x: number, y: number): boolean { * The shared `hittable` predicate every iOS snapshot producer publishes (#1933): an enabled node * with a positive finite frame whose center falls inside the viewport. It is the TypeScript twin of * the runner's Swift `SnapshotGeometry.isGeometricallyActionable`, including `CGRect.contains`'s - * half-open right/bottom edges — a center landing exactly on the viewport's right or bottom edge is - * not hittable on either producer — and including the node-rect precondition, which Swift used to - * spell null/empty and so called a negative-width box, or one whose components are not finite, - * actionable there and not here (#2891). `contracts/fixtures/snapshot-actionability-policy.json` - * pins both sides. - * - * `viewport` has no unknown case to handle: every caller hands over a box its own producer declared - * positive and finite — `viewportFromRoot` in `packages/platform-apple/src/snapshot-source/tree.ts` - * before that path publishes the bit at all, and `resolveViewportEvidence` in - * `packages/capture-kit/src/ios-snapshot-engine/invariants.ts`, which throws - * `missing-viewport`/`invalid-viewport`, before the engine folds a regular presentation. Those - * refusals are this predicate's unknown-viewport case, and they fail in the same direction as the - * runner's, which carries the state as `SnapshotViewport.missing` and publishes no actionability - * (#2891). - * - * The host AX bridge derives the source bit from the node's own frame and the fold intersects it - * with the clipped frame, so a `hittable:` selector cannot tell the two producers apart. Kept here - * so both packages read one definition rather than each re-encoding the rule. + * half-open right/bottom edges; `contracts/fixtures/snapshot-actionability-policy.json` pins both. + * Callers without a viewport box withhold the bit instead of asking. The host AX bridge derives the + * source bit from the node's own frame and the fold intersects it with the clipped frame, so a + * `hittable:` selector cannot tell the two producers apart. */ export function isGeometricallyActionable( enabled: boolean, diff --git a/packages/platform-apple/src/runner/__tests__/snapshot-presentation.test.ts b/packages/platform-apple/src/runner/__tests__/snapshot-presentation.test.ts index a21f130552..917efddbd9 100644 --- a/packages/platform-apple/src/runner/__tests__/snapshot-presentation.test.ts +++ b/packages/platform-apple/src/runner/__tests__/snapshot-presentation.test.ts @@ -207,6 +207,33 @@ test('a healthy payload with valid viewport roots still presents', () => { ); }); +// A runner capture with no viewport box omits `hittable` on the nodes it could not decide (#2891); +// the host presents them undecided rather than minting a value. +test('a runner payload with the hittable bit absent presents without declaring it', () => { + const nodes: RawSnapshotNode[] = [ + { + index: 0, + type: 'Application', + rect: { x: 0, y: 0, width: 390, height: 844 }, + hittable: false, + }, + { index: 1, parentIndex: 0, type: 'Other', rect: { x: 0, y: 0, width: 390, height: 844 } }, + { + index: 2, + parentIndex: 1, + type: 'Button', + label: 'Not Now', + rect: { x: 16, y: 400, width: 80, height: 32 }, + }, + ]; + for (const interactiveOnly of [false, true]) { + const presented = presentAppleRunnerSnapshot('device-1', { interactiveOnly }, { nodes }); + const button = presented.find((node) => node.label === 'Not Now'); + assert.ok(button, `interactiveOnly=${interactiveOnly}: the undecided button is presented`); + assert.equal('hittable' in button, false); + } +}); + // The keyboard band the runner measured for a capture (#2660). The reader is the only place a wire // fact becomes a daemon fact, so it owns the whole strictness budget: what cannot be placed is // restated as `unmeasurable` with a reason, never as a band and never as silence. diff --git a/scripts/ios-snapshot-differential.test.ts b/scripts/ios-snapshot-differential.test.ts index 0f8ee350af..60f3fdf8bc 100644 --- a/scripts/ios-snapshot-differential.test.ts +++ b/scripts/ios-snapshot-differential.test.ts @@ -3,10 +3,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; import fc from 'fast-check'; -import type { IosViewportEvidence } from '@agent-device/contracts/ios-snapshot'; import { isGeometricallyActionable, isPositiveFiniteRect } from '@agent-device/kernel/rect'; import type { Rect } from '@agent-device/kernel/snapshot'; -import { resolveViewportEvidence } from '../packages/capture-kit/src/ios-snapshot-engine/invariants.ts'; import { compareDifferentialCases, swiftToolchainAvailable, @@ -144,7 +142,8 @@ type ActionabilityVector = Readonly<{ enabled: boolean; node: ActionabilityRect | ActionabilityUnusableRect; viewport: ActionabilityViewport; - hittable: boolean; + /** `null` is the absent bit. */ + hittable: boolean | null; nodeRectGuardPasses: boolean; }>; @@ -164,10 +163,6 @@ const NON_FINITE_RECT: Rect = { height: Number.POSITIVE_INFINITY, }; -/** - * A row one language skips is a written-down divergence, and a divergence without a reason is how two - * implementations start disagreeing quietly again: a shared row carries no reason, a skipped row one. - */ function declaresItsAsymmetry(vector: ActionabilityVector): boolean { const hasReason = typeof vector.asymmetry === 'string' && vector.asymmetry.length > 0; return (vector.swift && vector.typescript) !== hasReason; @@ -209,15 +204,8 @@ function toRect(node: ActionabilityVector['node']): Rect { return node; } -function missingViewportReason(reason: 'not-provided' | 'invalid'): string { - return reason === 'invalid' ? 'invalid-viewport' : 'missing-viewport'; -} - -// The Swift twin of these same rows is ActionabilityPolicyTests in -// apple/snapshot-presentation/Tests, run by `swift test --package-path apple/snapshot-presentation` -// in this very command. The fold differential above cannot carry them: the host engine refuses to -// fold a regular presentation at all without a positive finite viewport (`resolveViewportEvidence`), -// so an unknown viewport has no TypeScript fold outcome to compare a runner outcome against. +// The Swift twin of these rows is ActionabilityPolicyTests, run by `swift test --package-path +// apple/snapshot-presentation` in this same command. test('the shared hittable predicate agrees with every golden actionability vector', () => { for (const vector of readActionabilityVectors().filter((row) => row.typescript)) { const node = toRect(vector.node); @@ -227,15 +215,7 @@ test('the shared hittable predicate agrees with every golden actionability vecto `${vector.name}: node-rect guard`, ); if (vector.viewport.kind === 'missing') { - const evidence: IosViewportEvidence = vector.viewport; - const expectedReason = missingViewportReason(vector.viewport.reason); - assert.throws( - () => resolveViewportEvidence(evidence), - (error: unknown) => (error as { reason?: string }).reason === expectedReason, - `${vector.name}: the host declines the capture rather than answer the predicate`, - ); - assert.equal(vector.hittable, false, `${vector.name}: the unknown viewport fails closed`); - continue; + throw new Error(`${vector.name}: the TypeScript predicate takes a box`); } assert.equal( isGeometricallyActionable(vector.enabled, node, vector.viewport.rect), @@ -245,19 +225,10 @@ test('the shared hittable predicate agrees with every golden actionability vecto } }); -test('the actionability table covers every viewport kind without a vacuous missing row', () => { +test('the TypeScript rows cover every viewport kind that carries a box', () => { const vectors = readActionabilityVectors().filter((row) => row.typescript); assert.deepEqual([...new Set(vectors.map((vector) => vector.viewport.kind))].sort(), [ 'derived', - 'missing', 'reported', ]); - for (const vector of vectors) { - if (vector.viewport.kind !== 'missing') continue; - assert.equal( - vector.nodeRectGuardPasses, - true, - `${vector.name}: a missing-viewport row needs a node the guard accepts`, - ); - } }); From 771322a9e9246a45dd8d641c94122c0081306d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 14:56:47 +0200 Subject: [PATCH 7/7] chore(gates): route the actionability vector table to the differential lane contracts/fixtures/snapshot-actionability-policy.json is read by both sides of scripts/ios-snapshot-differential, and packages/kernel/src/rect.ts holds the TypeScript predicate that table replays, so a change to either must select that lane rather than rely on the generic golden-table rule reaching only the unit and Swift builds. --- scripts/check-affected/model.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index d21e10de21..259e53fb68 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -453,7 +453,9 @@ const BUILD_OWNERSHIP: ReadonlyArray<{ owns: (file) => file.startsWith('packages/capture-kit/src/ios-snapshot-engine/') || file.startsWith('apple/snapshot-presentation/') || - file === 'contracts/fixtures/ios-snapshot-engine-conformance.json', + file === 'packages/kernel/src/rect.ts' || + file === 'contracts/fixtures/ios-snapshot-engine-conformance.json' || + file === 'contracts/fixtures/snapshot-actionability-policy.json', }, // Both platform builds compile the same runner sources, and each is a separate // gate in a separate lane, so a Swift change owns both.