diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift index 855abbd25b..462cdfd667 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift @@ -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 diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift index ee6c4400dd..6c1536cff7 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift @@ -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 ) } @@ -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, diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift index d0db9a8e24..176090a320 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift @@ -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 } diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift index d0421aab6d..7723411050 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift @@ -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 } } @@ -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) } } diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift index 9f9a4d3ea3..0a3503f770 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift @@ -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, @@ -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. + 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 + ) + } + } diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift index 27882e1442..c3a65eaad8 100644 --- a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ActionabilityPolicyTests.swift @@ -75,6 +75,10 @@ final class ActionabilityPolicyTests: XCTestCase { var declaresItsAsymmetry: Bool { (swift && typescript) != (asymmetry?.isEmpty == false) } + + var runsSomewhere: Bool { + swift || typescript + } } func testActionabilityPolicyAgreesWithEveryGoldenVector() throws { @@ -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" @@ -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)" + ) + } + } } diff --git a/contracts/fixtures/snapshot-actionability-policy.json b/contracts/fixtures/snapshot-actionability-policy.json index 5a567f6495..69306fe038 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, #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", @@ -122,10 +122,9 @@ "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 } }, @@ -133,7 +132,22 @@ "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, diff --git a/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index 9610cc8bb9..66e507a6f3 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -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. diff --git a/packages/kernel/src/rect.test.ts b/packages/kernel/src/rect.test.ts index e3c31814d1..7f9bfdd781 100644 --- a/packages/kernel/src/rect.test.ts +++ b/packages/kernel/src/rect.test.ts @@ -4,12 +4,62 @@ import type { Rect } from './snapshot.ts'; import { containsPoint, isGeometricallyActionable, + isPositiveFiniteRect, isRectVisibleInViewport, pickLargestRect, } from './rect.ts'; const VIEWPORT: Rect = { x: 0, y: 0, width: 300, height: 500 }; +/** `CGRectInfinite` spelled in the doubles Apple spells it with: what a failed read crosses a wire in. */ +const CG_RECT_INFINITE: Rect = { + x: -Number.MAX_VALUE / 2, + y: -Number.MAX_VALUE / 2, + width: Number.MAX_VALUE, + height: Number.MAX_VALUE, +}; + +test('isPositiveFiniteRect refuses the three boxes its numeric twins cannot measure (#2891)', () => { + assert.equal(isPositiveFiniteRect({ x: 0, y: 0, width: 390, height: 844 }), true); + assert.equal(isPositiveFiniteRect(CG_RECT_INFINITE), false, 'the Apple no-box sentinel'); + assert.equal( + isPositiveFiniteRect({ x: Number.MAX_VALUE, y: 0, width: Number.MAX_VALUE, height: 1 }), + false, + 'finite components that overflow their own right edge', + ); + assert.equal( + isPositiveFiniteRect({ x: 0, y: 0, width: Number.POSITIVE_INFINITY, height: 1 }), + false, + ); + assert.equal(isPositiveFiniteRect({ x: 0, y: 0, width: 10, height: Number.NaN }), false); + assert.equal(isPositiveFiniteRect({ x: 0, y: 0, width: 0, height: 10 }), false); + assert.equal(isPositiveFiniteRect({ x: 0, y: 0, width: -10, height: 10 }), false); + assert.equal(isPositiveFiniteRect(undefined), false); +}); + +/** + * Non-vacuity for the sentinel: every component and every extent of it is finite, so the guard's + * identity refusal is the only thing standing between a failed viewport read and a box that + * contains every node center on the screen. + */ +test('the sentinel would survive any check that only looks at components and extents', () => { + const components = [ + CG_RECT_INFINITE.x, + CG_RECT_INFINITE.y, + CG_RECT_INFINITE.width, + CG_RECT_INFINITE.height, + ]; + assert.ok(components.every(Number.isFinite)); + assert.ok(Number.isFinite(CG_RECT_INFINITE.x + CG_RECT_INFINITE.width)); + assert.ok(Number.isFinite(CG_RECT_INFINITE.y + CG_RECT_INFINITE.height)); + assert.equal(CG_RECT_INFINITE.x + CG_RECT_INFINITE.width / 2, 0, 'its center is (0, 0)'); + assert.equal( + isPositiveFiniteRect({ ...CG_RECT_INFINITE, height: 123 }), + true, + 'one byte off is a real box', + ); +}); + test('containsPoint is inclusive on every edge and requires all four bounds', () => { assert.equal(containsPoint(VIEWPORT, 0, 0), true); assert.equal(containsPoint(VIEWPORT, 300, 500), true); diff --git a/packages/kernel/src/rect.ts b/packages/kernel/src/rect.ts index a6ebb19bc5..8321eddce8 100644 --- a/packages/kernel/src/rect.ts +++ b/packages/kernel/src/rect.ts @@ -1,15 +1,45 @@ import type { Rect } from './snapshot.ts'; -/** Twin of `SnapshotGeometry.isPositiveFinite` on the runner (#2891). */ -export function isPositiveFiniteRect(rect: Rect | undefined): rect is Rect { - return Boolean( - rect && - [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && - rect.width > 0 && - rect.height > 0, +/** + * CoreGraphics' `CGRectInfinite`, spelled in the four doubles Apple builds it from. This is what a + * failed viewport or frame read looks like once it has crossed a JSON wire: every component of it + * is finite, and so is every extent, and its center is `(0, 0)` (#2891). + */ +const CG_RECT_INFINITE: Rect = { + x: -Number.MAX_VALUE / 2, + y: -Number.MAX_VALUE / 2, + width: Number.MAX_VALUE, + height: Number.MAX_VALUE, +}; + +function isCGRectInfinite(rect: Rect): boolean { + return ( + rect.x === CG_RECT_INFINITE.x && + rect.y === CG_RECT_INFINITE.y && + rect.width === CG_RECT_INFINITE.width && + rect.height === CG_RECT_INFINITE.height ); } +/** + * Twin of `SnapshotGeometry.isPositiveFinite` on the runner, and the guard every viewport read passes + * a box through before it becomes evidence (#2891). Three refusals, each reachable by a different + * input: a non-finite component; a box whose finite components still overflow its own right or + * bottom edge; and `CGRect.infinite`, which the two numeric checks let through and which the Swift + * twin refuses with `!rect.isInfinite`. The sentinel is refused by value here rather than in one + * producer's parser because the producers are many — the simulator AX bridge, the runner wire, a + * remote provider's tree — and a viewport box that reaches the guard-free predicate makes every node + * center on the screen land inside it. + */ +export function isPositiveFiniteRect(rect: Rect | undefined): rect is Rect { + if (!rect) return false; + const { x, y, width, height } = rect; + if (![x, y, width, height].every(Number.isFinite)) return false; + if (!Number.isFinite(x + width) || !Number.isFinite(y + height)) return false; + if (width <= 0 || height <= 0) return false; + return !isCGRectInfinite(rect); +} + export function rectContains(container: Rect, nested: Rect): boolean { return ( nested.x >= container.x && @@ -33,9 +63,13 @@ export function containsPoint(rect: Rect, x: number, y: number): boolean { * 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; `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. + * Callers without a viewport box withhold the bit instead of asking. The `viewport` argument is + * trusted rather than re-checked, because a `boolean`-returning rule cannot answer "no idea": pass a + * box {@link isPositiveFiniteRect} accepted. The Swift twin needs no such request because it takes + * `SnapshotViewport`, whose `.missing` case is the absence of a box, and returns `Bool?`, which can. + * 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/snapshot-source/tree.test.ts b/packages/platform-apple/src/snapshot-source/tree.test.ts index 553c3a4e98..6d7e01773b 100644 --- a/packages/platform-apple/src/snapshot-source/tree.test.ts +++ b/packages/platform-apple/src/snapshot-source/tree.test.ts @@ -135,6 +135,40 @@ test('the bridge reader stamps geometric hittable onto every raw node, matching ); }); +test('a root whose frame is the Apple no-box sentinel declares an invalid viewport, not a whole-screen one (#2891)', () => { + // Every component and extent of this box is finite, so only the shared box guard refuses it. If + // it were taken as a reported viewport, every node center on the screen would land inside it. + const sentinel = { + X: -Number.MAX_VALUE / 2, + Y: -Number.MAX_VALUE / 2, + Width: Number.MAX_VALUE, + Height: Number.MAX_VALUE, + }; + const result = decodeSnapshotBridgeTree( + { + [application]: 'Application', + [frame]: sentinel, + [children]: [ + { + [automationType]: 9, + [label]: 'Continue', + [frame]: { X: 20, Y: 700, Width: 120, Height: 48 }, + [children]: [], + }, + ], + }, + { truncated: false }, + limits, + ); + + assert.deepEqual(result.viewport, { kind: 'missing', reason: 'invalid' }); + assert.deepEqual( + result.nodes.map((node) => node.hittable), + [undefined, undefined], + 'a refused viewport publishes no hittable claim at all', + ); +}); + test('the bridge tree counts web-hosted remote leaves that reach the viewport', () => { const viewport = { X: 0, Y: 0, Width: 390, Height: 844 }; const remoteLeaf = (rect?: Record) => ({ diff --git a/scripts/ios-snapshot-differential.test.ts b/scripts/ios-snapshot-differential.test.ts index 60f3fdf8bc..52cecb85ca 100644 --- a/scripts/ios-snapshot-differential.test.ts +++ b/scripts/ios-snapshot-differential.test.ts @@ -163,6 +163,17 @@ const NON_FINITE_RECT: Rect = { height: Number.POSITIVE_INFINITY, }; +/** + * What `{"infinite": true}` means on this side: `CGRect.infinite` spelled in the doubles Apple + * spells it with, which is exactly the shape a failed read arrives in over a JSON wire. + */ +const CG_RECT_INFINITE: Rect = { + x: -Number.MAX_VALUE / 2, + y: -Number.MAX_VALUE / 2, + width: Number.MAX_VALUE, + height: Number.MAX_VALUE, +}; + function declaresItsAsymmetry(vector: ActionabilityVector): boolean { const hasReason = typeof vector.asymmetry === 'string' && vector.asymmetry.length > 0; return (vector.swift && vector.typescript) !== hasReason; @@ -185,6 +196,10 @@ function readActionabilityVectors(): readonly ActionabilityVector[] { 'boolean', `${vector.name}: row must declare the TypeScript side`, ); + assert.ok( + vector.swift || vector.typescript, + `${vector.name}: a row no language runs asserts nothing`, + ); assert.ok( declaresItsAsymmetry(vector), `${vector.name}: a row both languages do not share must name the asymmetry`, @@ -195,12 +210,7 @@ function readActionabilityVectors(): readonly ActionabilityVector[] { function toRect(node: ActionabilityVector['node']): Rect { 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', - ); - } + if ('infinite' in node) return CG_RECT_INFINITE; return node; } @@ -215,7 +225,9 @@ test('the shared hittable predicate agrees with every golden actionability vecto `${vector.name}: node-rect guard`, ); if (vector.viewport.kind === 'missing') { - throw new Error(`${vector.name}: the TypeScript predicate takes a box`); + assert.fail( + `${vector.name}: the TypeScript predicate takes a box, so this row is Swift-only`, + ); } assert.equal( isGeometricallyActionable(vector.enabled, node, vector.viewport.rect), @@ -225,6 +237,37 @@ test('the shared hittable predicate agrees with every golden actionability vecto } }); +/** + * Non-vacuity for the row that started #2891: `CGRect.infinite` is built of finite components and + * finite extents, so if this spelling ever stopped being a value the numeric checks accept, the + * sentinel row would be silently replaying some other unusable box and the sentinel would go + * untested on this side. + */ +test('the infinite row is a box only the guard itself can refuse', () => { + const box = [ + CG_RECT_INFINITE.x, + CG_RECT_INFINITE.y, + CG_RECT_INFINITE.width, + CG_RECT_INFINITE.height, + ]; + assert.ok( + box.every(Number.isFinite), + "CGRect.infinite's components are finite Doubles; a spelling that is not cannot stand for it", + ); + assert.ok( + [ + CG_RECT_INFINITE.x + CG_RECT_INFINITE.width, + CG_RECT_INFINITE.y + CG_RECT_INFINITE.height, + ].every(Number.isFinite), + "CGRect.infinite's extents are finite too, so an extent check alone would accept it", + ); + assert.equal( + isPositiveFiniteRect(CG_RECT_INFINITE), + false, + "the guard itself is what refuses Apple's no-box sentinel", + ); +}); + 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(), [