From 085f1fd971887764eb92a4c18b84ad9062f2542b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 20:18:46 +0200 Subject: [PATCH 1/6] fix(ios): make both rect guards refuse the box a failed read leaves behind `isPositiveFiniteRect` compared four components, and `CGRect.infinite` is built of four finite Doubles whose center is (0, 0): x=y=-8.988465674311579e+307, w=h=1.7976931348623157e+308. The bridge's `rectDictionary` refuses only a non-finite component, so that box crossed the wire intact, `viewportFromRoot` called it `.reported`, and every node center on the screen landed inside it -- the host path publishing `hittable: true` for a whole tree whose viewport read had failed. The Swift twin already refused it with `!rect.isInfinite`; the two declarations of one rule disagreed on exactly the input that started #2891. Refused now, in both twins: a non-finite component, a box whose finite components overflow its own right or bottom edge, and the sentinel itself. The extent check is a separate hole, not the sentinel's fix -- the sentinel's own extents are finite, which is why a value refusal stays and is pinned as non-vacuous. It lives in the shared guard rather than in one producer's parser because every TypeScript producer (the simulator bridge, the runner wire, a remote provider's tree) feeds this one function. The old `isPlottable` accepted both newly-refused classes and nothing else changed: for every other box the five comparisons are the ones it had. --- .../SnapshotGeometry.swift | 11 +++-- .../adr/0004-ios-snapshot-backend-strategy.md | 5 ++- packages/kernel/src/rect.ts | 44 ++++++++++++++++--- 3 files changed, 48 insertions(+), 12 deletions(-) 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/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index 9610cc8bb9..656b0b37cd 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -309,7 +309,10 @@ 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 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.ts b/packages/kernel/src/rect.ts index a6ebb19bc5..5c4cd39845 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 one place a box becomes a fact + * this rule may plot or measure (#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 every TypeScript producer — the simulator AX bridge, the runner wire, a remote + * provider's tree — feeds this one guard, and a viewport box that survives it 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 && From cf3a3d3abe7bd5526dc5e602d955192decc9d900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 20:18:51 +0200 Subject: [PATCH 2/6] test(ios): replay the sentinel and an unmeasurable box in both languages The sentinel row declared `typescript: false` on the claim that `frameFromGuest` would refuse the frame before the predicate saw it. Its components are finite and non-negative, so that refusal never happens, and the row's `asymmetry` note said the shared rule contained a magic value it did not have. The row now runs in both languages against the guard that actually refuses the box. Adds the overflow class the component checks cannot reach, and pins three things that were previously only argued in prose: a refused box never becomes a declared viewport on either side; the wire carries no `hittable` key at all when the viewport is unknown, while a disabled node and a root stay declared `false`; and a table row neither language runs is now rejected instead of accepted. Non-vacuity, verified by mutation: dropping `!rect.isInfinite` reddens the Swift rows and the declaration test; dropping the value refusal from the TypeScript guard reddens the kernel test and the bridge reader's viewport case; flipping a row to `swift: false, typescript: false` reddens both lanes. --- ...unnerTests+SnapshotPresentationTests.swift | 50 ++++++++++++++++ .../ActionabilityPolicyTests.swift | 32 +++++++++++ .../snapshot-actionability-policy.json | 24 ++++++-- packages/kernel/src/rect.test.ts | 50 ++++++++++++++++ .../src/snapshot-source/tree.test.ts | 34 +++++++++++ scripts/ios-snapshot-differential.test.ts | 57 ++++++++++++++++--- 6 files changed, 235 insertions(+), 12 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift index 855abbd25b..8ab2eb1d0e 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift @@ -622,5 +622,55 @@ 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` bit at all. Pinned on the + /// encoded objects rather than the decoded model, because `false` and an absent bit are the same + /// `Bool?` in Swift and only the wire tells them apart — and 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: 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/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..b359d7dc6b 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 and finite extents can still spell an unmeasurable box: it overflows its own right edge, 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/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/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(), [ From f620148f606bf4a68de36854bdc8444a02ba3035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 20:18:57 +0200 Subject: [PATCH 3/6] fix(ios): check a viewport box on the way into the declared fact `Box`'s memberwise initializer was internal, so any code in the package could still write `Box(positiveFinite: .infinite)` and wrap it in `.reported`: the guarantee held for callers outside the module and nowhere else, while the comment on the type claimed the factories were the only constructors. The check moves into the initializer, which is now failable, so an unchecked box is not a thing a caller can name and the factories simply translate a refusal into `.missing(reason: .invalid)`. This is a compile-shape guarantee, so it has no runtime test of its own; the declaration test added alongside it covers the behavior both callers observe. --- .../SnapshotModels.swift | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) 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) } } From c3f80a85963c36b4f9d74982d25f44ef37a00416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 20:24:24 +0200 Subject: [PATCH 4/6] fix(ios): let the fold answer a carrier its own clip already decided A third review pass at `f620148f60` refuted the reason this had been left alone: `shouldInclude` exempts `Application` and `Window` from the visibility test, so a carrier a scroll anchor clips to nothing is RETAINED, and it reaches the `hittable` decision with the source bit undecided. There `flatMap` over the source bit publishes absence for a node whose own frame is degenerate, which is exactly the corner where ADR 0004 promises "disabled or degenerate nodes stay declared `false`" and where the `nil` case is documented as "only containment is left to decide". The source bit is now consulted only for a declared `false`; anything undecided is re-decided on the clipped frame, so `nil` means containment is the open question and nothing else. With a box the source bit is never `nil`, so the reported-viewport path is untouched. Also two comments that overclaimed: the wire test does not prove what the Swift encoder could regress into, and the TypeScript predicate does not validate the viewport it is handed -- it cannot, because it answers in `boolean`, so the requirement on callers is now stated where a caller reads it. --- ...unnerTests+SnapshotPresentationTests.swift | 5 +-- ...nerTests+SnapshotVisibilityFoldTests.swift | 31 +++++++++++++++++ .../SnapshotVisibilityFold.swift | 34 ++++++++++++++----- packages/kernel/src/rect.ts | 23 +++++++------ 4 files changed, 72 insertions(+), 21 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift index 8ab2eb1d0e..1787c37ec6 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift @@ -624,8 +624,9 @@ extension RunnerTests { } /// #2891: a capture whose viewport read failed publishes no `hittable` bit at all. Pinned on the - /// encoded objects rather than the decoded model, because `false` and an absent bit are the same - /// `Bool?` in Swift and only the wire tells them apart — and the #2638 wrapper verdict reads a + /// 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( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift index ee6c4400dd..3566958535 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift @@ -152,6 +152,37 @@ 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 { + let nodes: [RawAXNode] = [ + 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), + Self.foldNode(2, type: "Window", label: "Clipped carrier", + rect: SnapshotRect(x: 0, y: 900, width: 402, height: 52), depth: 2, parentIndex: 1), + Self.foldNode(3, type: "Button", label: "Inside", + rect: SnapshotRect(x: 0, y: 120, width: 402, height: 52), depth: 2, parentIndex: 1), + ].map { node in + // What `normalized()` leaves behind when the viewport read failed: undecided, not `false`. + node.replacing(rect: node.rect, hittable: node.parentIndex == nil ? false : 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/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/packages/kernel/src/rect.ts b/packages/kernel/src/rect.ts index 5c4cd39845..6b23401bd6 100644 --- a/packages/kernel/src/rect.ts +++ b/packages/kernel/src/rect.ts @@ -22,13 +22,13 @@ function isCGRectInfinite(rect: Rect): boolean { } /** - * Twin of `SnapshotGeometry.isPositiveFinite` on the runner, and the one place a box becomes a fact - * this rule may plot or measure (#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 every TypeScript producer — the simulator AX bridge, the runner wire, a remote - * provider's tree — feeds this one guard, and a viewport box that survives it makes every node + * 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 { @@ -63,9 +63,12 @@ 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, since an unknown + * viewport is not representable in its `SnapshotViewport`. 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, From 53a666b391f4fad0406eb6caa55f75a4b1604d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 21:02:13 +0200 Subject: [PATCH 5/6] test(ios): compile the two new runner assertions Both were added without compiling the runner target, which is not reachable from the differential lane: `SnapshotRect` takes Doubles and the new fold test spelled its y offset in Ints, and the fold test reached for `RawAXNode.replacing`, which is internal to the presentation module and invisible to the runner tests. The fold cases now state their source bit where the node is built, through a defaulted parameter on the test's own builder, which is also what `normalized()` leaves behind when the viewport read failed. Verified with the runner test target built with `AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1` and run on the `rnav-repro` simulator: 9 selected cases, 0 failures, including both new ones and `testRegularFoldKeepsWindowCarriersButNeverHittableOutsideClip`, which is the reported-viewport neighbour of the new fold policy. --- ...unnerTests+SnapshotPresentationTests.swift | 2 +- ...nerTests+SnapshotVisibilityFoldTests.swift | 23 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift index 1787c37ec6..bf8b99cbb2 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift @@ -635,7 +635,7 @@ extension RunnerTests { ) -> RawAXNode { RawAXNode( index: index, type: type, label: label, identifier: nil, value: nil, - rect: SnapshotRect(x: 10, y: 20 + index * 60, width: 100, height: 44), + 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 ) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift index 3566958535..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 ) } @@ -157,19 +158,21 @@ extension RunnerTests { /// 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 { - let nodes: [RawAXNode] = [ + // 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), + 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), + 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), - ].map { node in - // What `normalized()` leaves behind when the viewport read failed: undecided, not `false`. - node.replacing(rect: node.rect, hittable: node.parentIndex == nil ? false : nil) - } + 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), From 1e1fa90d84fbeda29c4aa13f39a1d1c335e6ec70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 21:14:30 +0200 Subject: [PATCH 6/6] docs(ios): say what the narrowed guard and the new tests actually promise Four claims around the previous commits were wider than the code. `rect.ts` justified trusting its `viewport` argument by saying an unknown viewport is not representable in `SnapshotViewport`, which has a `.missing` case for exactly that. The reason the Swift twin needs no such request is that it takes that fact and returns `Bool?`, so it can answer "no idea"; a `boolean`-returning rule cannot, which is why the request exists at all. The wire test's comment promised that a failed viewport read publishes no `hittable` at all, over its own assertions, which require a declared `false` from the root and from the disabled node. The bit that goes absent is the one containment would have decided. The overflow fixture row said the box has finite extents and then handed it to a guard that refuses it because `x + width` is infinity. Its components are finite; its right edge is not. The ADR paragraph on the two guards said the guard being replaced had accepted both newly-refused classes. Nothing was replaced here: the Swift twin already refused the sentinel by identity, and this branch adds the extent check there and both refusals on the TypeScript side. The narrowing now names which twin lost what, which is also the answer to whether any input changed classification. Differential lane green after the row rename: Swift 17 tests, node 6 tests, 0 failures. Runner test target rebuilt with unit tests and rerun on the iPhone 17 / iOS 26.2 simulator: 9 selected cases, 0 failures. --- .../UnitTests/RunnerTests+SnapshotPresentationTests.swift | 3 ++- contracts/fixtures/snapshot-actionability-policy.json | 2 +- docs/adr/0004-ios-snapshot-backend-strategy.md | 5 ++++- packages/kernel/src/rect.ts | 5 +++-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift index bf8b99cbb2..462cdfd667 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift @@ -623,7 +623,8 @@ extension RunnerTests { XCTAssertNotNil(keyQ, "`q` key must be presented under --depth \(options.depth ?? -1)") } - /// #2891: a capture whose viewport read failed publishes no `hittable` bit at all. Pinned on the + /// #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 diff --git a/contracts/fixtures/snapshot-actionability-policy.json b/contracts/fixtures/snapshot-actionability-policy.json index b359d7dc6b..69306fe038 100644 --- a/contracts/fixtures/snapshot-actionability-policy.json +++ b/contracts/fixtures/snapshot-actionability-policy.json @@ -132,7 +132,7 @@ "nodeRectGuardPasses": false }, { - "name": "finite components and finite extents can still spell an unmeasurable box: it overflows its own right edge, so the extent check refuses it", + "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, diff --git a/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index 656b0b37cd..66e507a6f3 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -312,7 +312,10 @@ root clip to violate, and a node whose actionability depends on containment has 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 runner route's +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.ts b/packages/kernel/src/rect.ts index 6b23401bd6..8321eddce8 100644 --- a/packages/kernel/src/rect.ts +++ b/packages/kernel/src/rect.ts @@ -65,8 +65,9 @@ export function containsPoint(rect: Rect, x: number, y: number): boolean { * 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 `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, since an unknown - * viewport is not representable in its `SnapshotViewport`. The host AX bridge derives the source bit + * 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. */