Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -622,5 +622,57 @@ extension RunnerTests {
XCTAssertNotNil(band, "keyboard plane must be presented under --depth \(options.depth ?? -1)")
XCTAssertNotNil(keyQ, "`q` key must be presented under --depth \(options.depth ?? -1)")
}

/// #2891: a capture whose viewport read failed publishes no `hittable` for a node whose answer is
/// containment, while a root and a disabled node stay a declared `false`. Pinned on the
/// encoded objects rather than on `capture.nodes`, because the promise is about the wire: a test
/// over the Swift values would still pass if a custom encoder started writing `"hittable":null`
/// for `nil`, which is a shape no host decoder is specified for. The #2638 wrapper verdict reads a
/// declared `false` as evidence that the wrapper is inert.
func testAMissingViewportOmitsTheHittableBitFromTheWire() throws {
func node(
_ index: Int, _ type: String, _ label: String,
enabled: Bool, parent: Int?, depth: Int
) -> RawAXNode {
RawAXNode(
index: index, type: type, label: label, identifier: nil, value: nil,
rect: SnapshotRect(x: 10, y: Double(20 + index * 60), width: 100, height: 44),
enabled: enabled, focused: nil, selected: nil, hittable: false,
depth: depth, parentIndex: parent, hiddenContentAbove: nil, hiddenContentBelow: nil
)
}
let acquired = [
node(0, "Application", "App", enabled: true, parent: nil, depth: 0),
node(1, "Button", "Continue", enabled: true, parent: 0, depth: 1),
node(2, "Button", "Sold out", enabled: false, parent: 0, depth: 1),
]
let viewport = SnapshotViewport.missing(reason: .notProvided)
let options = PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false)
let capture = try XCTUnwrap(try SnapshotPresentation.present(
SnapshotAcquisition(
hint: SnapshotPresentation.captureHint(for: options),
nodes: SnapshotGeometrySpace.normalized(nodes: acquired, viewport: viewport),
truncated: false,
effectiveDepth: nil,
viewport: viewport
),
options: options
))
let objects = try XCTUnwrap(
JSONSerialization.jsonObject(with: JSONEncoder().encode(capture.nodes)) as? [[String: Any]]
)

// Containment is the one thing a capture with no box cannot decide, so the key is not there.
let undecided = try XCTUnwrap(objects.first { $0["label"] as? String == "Continue" })
XCTAssertFalse(
undecided.keys.contains("hittable"),
"an unknown viewport publishes no bit: \(undecided.keys.sorted())"
)
// Enablement and the root rule need no box, so those stay declared answers rather than gaps.
let disabled = try XCTUnwrap(objects.first { $0["label"] as? String == "Sold out" })
XCTAssertEqual(disabled["hittable"] as? Bool, false, "a disabled node is decided without a box")
let root = try XCTUnwrap(objects.first { $0["label"] as? String == "App" })
XCTAssertEqual(root["hittable"] as? Bool, false, "a root has nothing to hit through")
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ extension RunnerTests {
label: String? = nil,
rect: SnapshotRect,
depth: Int,
parentIndex: Int?
parentIndex: Int?,
hittable: Bool? = false
) -> RawAXNode {
RawAXNode(
index: index, type: type, label: label, identifier: nil, value: nil, rect: rect,
enabled: true, focused: nil, selected: nil, hittable: false, depth: depth,
enabled: true, focused: nil, selected: nil, hittable: hittable, depth: depth,
parentIndex: parentIndex, hiddenContentAbove: nil, hiddenContentBelow: nil
)
}
Expand Down Expand Up @@ -152,6 +153,39 @@ extension RunnerTests {
XCTAssertFalse(cursorProjected.contains { $0.raw.label == "Outside scroll clip" })
}

/// #2891: with no viewport box the fold withholds the bit for everything whose answer is
/// containment, and still answers for what its own frame decides. A `visibilityExempt` carrier is
/// retained even when a scroll anchor clips it to nothing, so it is the retained node whose frame
/// is degenerate -- and `nil` there would hand the #2638 wrapper verdict neither answer.
func testFoldWithUnknownViewportWithholdsContainmentButNotFrameEvidence() throws {
// Source bits as `normalized()` leaves them when the viewport read failed: the root is decided,
// everything whose answer is containment is undecided.
let nodes = [
Self.foldNode(0, type: "Application", label: "App",
rect: SnapshotRect(x: 0, y: 0, width: 402, height: 874), depth: 0, parentIndex: nil),
Self.foldNode(1, type: "ScrollView", label: "Scroll",
rect: SnapshotRect(x: 0, y: 96, width: 402, height: 700), depth: 1, parentIndex: 0,
hittable: nil),
Self.foldNode(2, type: "Window", label: "Clipped carrier",
rect: SnapshotRect(x: 0, y: 900, width: 402, height: 52), depth: 2, parentIndex: 1,
hittable: nil),
Self.foldNode(3, type: "Button", label: "Inside",
rect: SnapshotRect(x: 0, y: 120, width: 402, height: 52), depth: 2, parentIndex: 1,
hittable: nil),
]
let folded = SnapshotVisibilityFold.fold(
nodes,
viewport: .missing(reason: .notProvided),
interactiveOnly: false,
policy: .cursorProjected
)

let carrier = try XCTUnwrap(folded.first { $0.raw.label == "Clipped carrier" })
XCTAssertEqual(carrier.raw.hittable, false, "a clipped-to-nothing frame is evidence, not a gap")
let inside = try XCTUnwrap(folded.first { $0.raw.label == "Inside" })
XCTAssertNil(inside.raw.hittable, "containment is undecided until a box exists")
}

func testScrollContainerTypeNamesMatchElementTypeSet() {
XCTAssertEqual(
SnapshotVisibilityFold.scrollContainerTypeNames,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import Foundation
import CoreGraphics

public enum SnapshotGeometry {
/// Twin of `isPositiveFiniteRect` in `packages/kernel/src/rect.ts`. `CGRect.infinite` is built
/// from finite components, so it is refused by identity.
/// Twin of `isPositiveFiniteRect` in `packages/kernel/src/rect.ts`, and the one place the
/// `hittable` rule asks whether a box may be plotted or measured (#2891). Three refusals, each
/// reachable by a different input: a non-finite component, a finite box wide enough to overflow
/// its own right or bottom edge, and `CGRect.infinite`, which is built of finite components and
/// finite extents and so is refused by identity alone.
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.minX.isFinite && rect.minY.isFinite
&& rect.maxX.isFinite && rect.maxY.isFinite
&& rect.size.width > 0 && rect.size.height > 0
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,14 @@ public struct PresentationOptions: Equatable {
/// 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 `SnapshotGeometry.isPositiveFinite` accepted. Only the factories below construct one.
/// A box that was checked on the way in. The initializer is the only gate: no caller, inside this
/// package or outside it, holds a `Box` whose rectangle `SnapshotGeometry.isPositiveFinite`
/// refuses, so a `.reported` case never needs re-checking what it was handed.
public struct Box: Equatable {
public let rect: CGRect

init(positiveFinite rect: CGRect) {
init?(checked rect: CGRect) {
guard SnapshotGeometry.isPositiveFinite(rect) else { return nil }
self.rect = rect
}
}
Expand Down Expand Up @@ -194,15 +197,13 @@ public enum SnapshotViewport: Equatable {
box: CGRect,
interfaceOrientation: Int = RunnerInterfaceOrientation.unknown
) -> SnapshotViewport {
SnapshotGeometry.isPositiveFinite(box)
? .reported(Box(positiveFinite: box), interfaceOrientation: interfaceOrientation)
: .missing(reason: .invalid)
guard let checked = Box(checked: box) else { return .missing(reason: .invalid) }
return .reported(checked, interfaceOrientation: interfaceOrientation)
}

public static func derived(box: CGRect) -> SnapshotViewport {
SnapshotGeometry.isPositiveFinite(box)
? .derived(Box(positiveFinite: box))
: .missing(reason: .invalid)
guard let checked = Box(checked: box) else { return .missing(reason: .invalid) }
return .derived(checked)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,15 +205,12 @@ public enum SnapshotVisibilityFold {
selected: node.selected,
hittable: node.parentIndex == nil
? false
: node.hittable.flatMap { sourceHittable in
sourceHittable
? SnapshotGeometry.isGeometricallyActionable(
enabled: node.enabled,
frame: decision.effectiveFrame,
viewport: viewport
)
: false
},
: clippedHittability(
source: node.hittable,
enabled: node.enabled,
clippedFrame: decision.effectiveFrame,
viewport: viewport
),
depth: outDepth,
parentIndex: keptIndex,
hiddenContentAbove: node.hiddenContentAbove,
Expand Down Expand Up @@ -244,4 +241,23 @@ public enum SnapshotVisibilityFold {
return applyHiddenContentHints(hints, to: kept)
}

/// The fold's share of the `hittable` policy (#2891). A declared `false` is kept; anything the
/// source left undecided is re-decided on the clipped frame, which refuses a disabled or
/// degenerate node with no viewport to consult and answers `nil` only while containment is the
/// open question. A `visibilityExempt` carrier clipped to nothing by a scroll anchor is that
/// `nil` today, and its own frame already answers the question.
Comment on lines +247 to +248

@cubic-dev-ai cubic-dev-ai Bot Sep 24, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This comment says a carrier clipped to nothing is nil today, but the helper now returns false; update the comment to match the implemented policy.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift, line 247:

<comment>This comment says a carrier clipped to nothing is `nil` today, but the helper now returns `false`; update the comment to match the implemented policy.</comment>

<file context>
@@ -244,4 +241,23 @@ public enum SnapshotVisibilityFold {
+  /// The fold's share of the `hittable` policy (#2891). A declared `false` is kept; anything the
+  /// source left undecided is re-decided on the clipped frame, which refuses a disabled or
+  /// degenerate node with no viewport to consult and answers `nil` only while containment is the
+  /// open question. A `visibilityExempt` carrier clipped to nothing by a scroll anchor is that
+  /// `nil` today, and its own frame already answers the question.
+  private static func clippedHittability(
</file context>
Suggested change
/// open question. A `visibilityExempt` carrier clipped to nothing by a scroll anchor is that
/// `nil` today, and its own frame already answers the question.
/// open question. A `visibilityExempt` carrier clipped to nothing by a scroll anchor is
/// `false` today, not `nil`, and its own frame already answers the question.
Fix with cubic

private static func clippedHittability(
source: Bool?,
enabled: Bool,
clippedFrame: CGRect,
viewport: SnapshotViewport
) -> Bool? {
if source == false { return false }
return SnapshotGeometry.isGeometricallyActionable(
enabled: enabled,
frame: clippedFrame,
viewport: viewport
)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ final class ActionabilityPolicyTests: XCTestCase {
var declaresItsAsymmetry: Bool {
(swift && typescript) != (asymmetry?.isEmpty == false)
}

var runsSomewhere: Bool {
swift || typescript
}
}

func testActionabilityPolicyAgreesWithEveryGoldenVector() throws {
Expand All @@ -89,6 +93,10 @@ final class ActionabilityPolicyTests: XCTestCase {
["reported", "derived", "missing"]
)
for testCase in table.cases {
XCTAssertTrue(
testCase.runsSomewhere,
"\(testCase.name): a row no language runs asserts nothing"
)
XCTAssertTrue(
testCase.declaresItsAsymmetry,
"\(testCase.name): a row both languages do not share must name the asymmetry"
Expand All @@ -115,4 +123,28 @@ final class ActionabilityPolicyTests: XCTestCase {
)
}
}

/// A box the guard refuses must never become a declared viewport, because one that is unbounded
/// contains every center on the screen and would make the whole tree actionable (#2891).
func testARefusedBoxNeverBecomesADeclaredViewport() {
let magnitude = CGFloat.greatestFiniteMagnitude
let overflowing = CGRect(x: magnitude, y: 0, width: magnitude, height: 1)
XCTAssertTrue(
CGRect.infinite.origin.x.isFinite && CGRect.infinite.size.width.isFinite,
"the sentinel is built of finite Doubles, so only the guard itself can refuse it"
)
XCTAssertTrue(CGRect.infinite.maxX.isFinite, "and so are its extents")
for refused in [CGRect.infinite, overflowing, CGRect.null, CGRect.zero] {
XCTAssertEqual(
SnapshotViewport.reported(box: refused),
.missing(reason: .invalid),
"a reported viewport refused \(refused)"
)
XCTAssertEqual(
SnapshotViewport.derived(box: refused),
.missing(reason: .invalid),
"a derived viewport refused \(refused)"
)
}
}
}
24 changes: 19 additions & 5 deletions contracts/fixtures/snapshot-actionability-policy.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"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`.",
"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 passes the box guard below, and its center falls inside the viewport box, half-open on the right and bottom edges. BOX GUARD: a box is usable when all four components are finite, both extents (x+width, y+height) are finite, the size is positive, and it is not CGRect.infinite — whose components and extents are all finite, so only identity refuses it. SnapshotGeometry.isPositiveFinite (Swift) and isPositiveFiniteRect in packages/kernel/src/rect.ts are the twins that enforce exactly this, one per language. Implementations of the predicate: 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); a box the guard refuses never becomes `reported`. `hittable: null` is the absent bit: with no viewport box, containment is undecided, while a disabled or degenerate node is still a declared `false` because that half of the rule needs no box. Every row declares `swift` and `typescript` and at least one must run it; a row one side skips carries `asymmetry`.",
"cases": [
{
"name": "a center strictly inside the reported box is actionable",
Expand Down Expand Up @@ -122,18 +122,32 @@
"nodeRectGuardPasses": true
},
{
"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",
"name": "Apple's no-box sentinel is not actionable on either language: its components and extents are all finite and its center is (0,0), so the box guard refuses it by value in both",
"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.",
"typescript": true,
"enabled": true,
"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",
"name": "finite components can still spell an unmeasurable box: the box overflows its own right edge out to infinity, so the extent check refuses it",
"swift": true,
"typescript": true,
"enabled": true,
"node": {
"x": 1.7976931348623157e308,
"y": 0,
"width": 1.7976931348623157e308,
"height": 1
},
"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 refused by the box guard before any center is computed; the bridge decoder refuses to decode such a frame earlier still, so this row pins the guard and not a producer",
"swift": true,
"typescript": true,
"enabled": true,
Expand Down
8 changes: 7 additions & 1 deletion docs/adr/0004-ios-snapshot-backend-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,13 @@ Inside the runner the viewport is a declared fact, not a rectangle: `SnapshotVie
`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
wire, as on the host bridge; disabled or degenerate nodes stay declared `false`. A rectangle becomes a
`Box` only through the initializer that checks it, and the shared guard refuses `CGRect.infinite` by
identity — its components and its extents are all finite, so no comparison would have caught the
value a failed read leaves behind. The two twins were not twins before #2908 landed: the Swift guard
already refused that value by identity, while the TypeScript one accepted it and both accepted finite
components whose right or bottom edge overflowed. Both now refuse both shapes, and every box either
guard accepted and is neither of those two shapes is still classified the same way. 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.
Expand Down
Loading
Loading