diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentedSurface.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentedSurface.swift index abf2b53c15..537ac6507f 100644 --- a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentedSurface.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentedSurface.swift @@ -1,97 +1,13 @@ import Foundation -/// What a capture's producer claims about its own completeness (#3565), stated by the caller rather -/// than inferred from the tree: `complete` when the producer reports the capture is not capped, `capped` -/// when it reports a cap, `unstated` when the producer observes nothing either way. A presented-surface -/// classification may not read a silent field as "complete". -public enum PresentedSurfaceCompleteness: Equatable { - case complete - case capped - case unstated -} - -/// One node as the presented-surface rule reads it: the element type every iOS producer publishes, and -/// the UIKit class name only some of them report. A `nil` `className` states a fact about the capture — -/// its producer reports no class names at all, which is true of every XCTest runner tier — so the rule -/// knows not to read window structure it was never given. -public struct PresentedSurfaceNode { - public let index: Int - public let parentIndex: Int? - public let elementType: String - public let className: String? - - public init(index: Int, parentIndex: Int?, elementType: String, className: String?) { - self.index = index - self.parentIndex = parentIndex - self.elementType = elementType - self.className = className - } -} - -extension PresentedSurfaceNode { - /// One acquired node. An accessibility-tree node carries no UIKit class name, which is what makes a - /// runner capture unable to earn `absent` rather than merely undecided. - public init(_ node: RawAXNode) { - self.init( - index: node.index, - parentIndex: node.parentIndex, - elementType: node.type, - className: nil - ) - } -} - -/// Reads a modal presentation out of a captured tree (#3565), before interactive filtering, compaction, -/// depth, or scope, so every view of one screen answers one value. -/// -/// This is the Swift half of a rule the TypeScript engine also runs (`collectPresentedSurface` in -/// `packages/capture-kit/src/ios-snapshot-engine/engine.ts`). The golden table -/// `contracts/fixtures/ios-presented-surface.json` pins the two against each other over the same tree -/// shapes, and each side mirrors the vocabulary below by hand the way the system-surface host registry -/// mirrors its own fixture. -/// -/// UIKit appends each modal presentation as a later sibling of the container it presents over, directly -/// under the window, and dims what is behind it with a dimming view that is a direct child of that -/// container — the same shape the occlusion cut rests on. Because this rule decides only whether -/// something is presented and not what the user can still reach, it deliberately does NOT read a -/// dimming view's enablement: a sheet resting at an undimmed detent keeps a dimming view that takes no -/// touches and is still presented. -/// -/// `absent` is earned and never defaulted, because a consumer arms a dismissal confirmation on it and a -/// wrong `absent` disarms that confirmation: the producer has to have stated the capture is complete, -/// every node has to carry the class name this rule reads, each node has to be numbered once so the -/// parent links this walk follows name the node they mean, and the one content window under the app root -/// has to hold exactly one presentation container. +/// Reads a modal presentation out of a runner acquisition (#3565). A runner node carries no UIKit class +/// name, so the only evidence it holds is an element type that names a presentation, and its answer is +/// `presented` or `unknown`, never `absent`. This is the class-blind column of the golden table +/// `contracts/fixtures/ios-presented-surface.json`; the class-aware rule is `collectPresentedSurface` in +/// `packages/capture-kit/src/ios-snapshot-engine/geometry-policy.ts`. public enum SnapshotPresentedSurface { - /// The container UIKit appends per presentation, and the one it presents app content inside. - public static let presentationContainerClass = "UITransitionView" - - /// The dimming view a presentation dims the content behind it with. Only a presentation installs one, - /// so one anywhere inside the app's own windows answers presented. Its position is not a fact this rule - /// may lean on: a live capture of a sheet holds one as a direct child of the presenting container AND - /// one two levels deep under the app's own container's drop shadow, so the rule never asks which - /// container a dimming view sits in. - public static let dimmingViewClass = "UIDimmingView" - - /// The private root classes of a UIAlertController. An iPhone alert arrives in its own window with no - /// dimming view anywhere in the capture, so these classes are the only evidence it holds. - public static let alertRootClassPrefix = "_UIAlertController" - - /// Windows UIKit hosts outside the app's own scene. They can carry none of the app's presentations, so - /// neither the window nor anything under its subtree counts as evidence in either direction. This is a - /// denylist of the hosts iOS is known to name: a window class it does not name is read as app content, - /// which is what keeps an app hosting its scene window in a subclass readable at all. The cost is that - /// an unnamed host carrying a dimming view of its own would read as presented; no such host has been - /// observed, while custom scene window classes are routine. - public static let systemWindowClasses: Set = [ - "UITextEffectsWindow", - "UIRemoteKeyboardWindow", - ] - - /// Element types that name a presentation wherever they appear, keyed by the shared element-type - /// numbering every iOS producer reads. A producer whose own name table stops short of the name - /// publishes `Element()` for the same node, so both spellings count and the raw-value spelling - /// is derived from these numbers rather than listed beside them. + /// Element types that name a presentation, keyed by the element-type numbering every iOS producer + /// reads. A producer whose name table stops short publishes `Element()` for the same node. public static let presentingElementTypes: [String: Int] = [ "Sheet": 5, "Drawer": 6, @@ -99,65 +15,8 @@ public enum SnapshotPresentedSurface { "Dialog": 8, ] - /// The fact for one captured tree, where `completeness` is the producer's own claim and never a - /// reading of the tree. - public static func classify( - nodes: [PresentedSurfaceNode], - completeness: PresentedSurfaceCompleteness - ) -> PresentedSurface { - let windowPositions = appWindowPositions(nodes) - let examinable = positionsOutsideSystemWindows(nodes, windowPositions) - // Positive evidence is evidence of what the producer did read, so a cap does not defeat it; absence - // is what a cap defeats, along with any capture that cannot state its own window structure. - if nodes.enumerated().contains(where: { examinable.contains($0.offset) && isPresentingEvidence($0.element) }) { - return .presented - } - guard completeness == .complete else { return .unknown } - guard !windowPositions.isEmpty, nodes.allSatisfy({ $0.className != nil }) else { return .unknown } - // `absent` is a claim about every window the producer read, and window structure is read through - // parent links, which cannot tell two nodes sharing a number apart. Positive evidence is already - // reported above, keyed on the node it was read off rather than on its number, so the most a - // collision can cost this rule is the answer it declines to make — never a false `absent`. - guard Set(nodes.map(\.index)).count == nodes.count else { return .unknown } - - let contentWindows = windowPositions.filter { !isSystemWindow(nodes[$0]) } - guard contentWindows.count == 1 else { return .unknown } - return presentationContainers(of: nodes[contentWindows[0]], in: nodes).count == 1 ? .absent : .unknown - } - - /// The fact for one acquisition as its producer holds it. public static func classify(_ acquisition: SnapshotAcquisition) -> PresentedSurface { - classify( - nodes: acquisition.nodes.map(PresentedSurfaceNode.init), - completeness: acquisition.truncated ? .capped : .complete - ) - } - - private static func isPresentingEvidence(_ node: PresentedSurfaceNode) -> Bool { - guard let className = node.className.map(normalizeClassName) else { - return isPresentingElementType(node.elementType) - } - if className == normalizeClassName(dimmingViewClass) { return true } - if className.hasPrefix(normalizeClassName(alertRootClassPrefix)) { return true } - return isPresentingElementType(node.elementType) - } - - /// Class names are read the way the TypeScript rule reads them: `normalizeType` in - /// `packages/contracts/src/snapshot-text.ts`, which the occlusion cut already matches UIKit classes - /// through. A producer that spells a class with a module prefix or in another case has to reach the - /// same answer in both languages, or the two readers disagree about the same capture, which is the - /// drift the shared golden table exists to catch. - private static func normalizeClassName(_ className: String) -> String { - var normalized = className.trimmingCharacters(in: .whitespacesAndNewlines) - normalized = normalized.replacingOccurrences(of: "XCUIElementType", with: "", options: .caseInsensitive) - if normalized.hasPrefix("AX") { - normalized = String(normalized.dropFirst(2)) - } - normalized = normalized.lowercased() - guard let separator = [".", "/"].compactMap({ normalized.lastIndex(of: $0) }).max() else { - return normalized - } - return String(normalized[separator...].dropFirst()) + acquisition.nodes.contains { isPresentingElementType($0.type) } ? .presented : .unknown } private static func isPresentingElementType(_ elementType: String) -> Bool { @@ -166,95 +25,13 @@ public enum SnapshotPresentedSurface { return presentingElementTypes.values.contains(rawValue) } - /// The raw element type behind the `Element()` spelling a producer uses for a type its own - /// name table does not cover. The number has to be bare digits: `Int` accepts a leading `+`, so a - /// numeric parse would read `Element(+5)` as Sheet, and the rule's answers would then depend on how - /// lenient one language's integer parser happens to be. + /// The number has to be bare ASCII digits: `Int` accepts a leading `+`, so a numeric parse would read + /// `Element(+5)` as Sheet where the TypeScript rule does not. private static func unnamedElementTypeRawValue(_ elementType: String) -> Int? { - guard elementType.hasPrefix(unnamedElementTypePrefix), elementType.hasSuffix(")") else { return nil } - let digits = elementType.dropFirst(unnamedElementTypePrefix.count).dropLast() + let prefix = "Element(" + guard elementType.hasPrefix(prefix), elementType.hasSuffix(")") else { return nil } + let digits = elementType.dropFirst(prefix.count).dropLast() guard !digits.isEmpty, digits.allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil } return Int(digits) } - - private static let unnamedElementTypePrefix = "Element(" - - private static func isSystemWindow(_ node: PresentedSurfaceNode) -> Bool { - guard let className = node.className.map(normalizeClassName) else { return false } - return systemWindowClasses.contains { normalizeClassName($0) == className } - } - - /// The app's windows: the nodes directly under its root, as the places in `nodes` they occupy. Both iOS - /// acquisition tiers report the app root and then one node per window, so finding them needs no class - /// facts — which is what lets a class-blind capture still be examined for the evidence it does report. - private static func appWindowPositions(_ nodes: [PresentedSurfaceNode]) -> [Int] { - let roots = nodes.enumerated().filter { $0.element.parentIndex == nil }.map(\.offset) - guard roots.count == 1 else { return [] } - let rootIndex = nodes[roots[0]].index - return nodes.enumerated().filter { $0.element.parentIndex == rootIndex }.map(\.offset) - } - - /// The nodes to read evidence from: every node except the ones the walk reached from a window UIKit - /// hosts outside the app's scene, keyed by the place in `nodes` each occupies rather than by its number. - /// A walk has to reach the node it is standing on, and two nodes sharing a number cannot be told apart - /// by the links that reach them; keying on the number would let a keyboard host's subtree swallow an - /// app window's own evidence, which is a direct read the rule would then throw away. Keying on the - /// occurrence keeps that read, and the numbering guard in `classify` is what stops such a tree from - /// earning absence. A capture with no class facts cannot single any window out, which is the honest - /// reading: nothing in it is excludable. - private static func positionsOutsideSystemWindows( - _ nodes: [PresentedSurfaceNode], - _ windowPositions: [Int] - ) -> Set { - var positionsByParentIndex: [Int: [Int]] = [:] - for (position, node) in nodes.enumerated() { - guard let parentIndex = node.parentIndex else { continue } - positionsByParentIndex[parentIndex, default: []].append(position) - } - var examinable = Set(nodes.indices) - for windowPosition in windowPositions where isSystemWindow(nodes[windowPosition]) { - for occurrence in subtreePositions(windowPosition, in: nodes, byParentIndex: positionsByParentIndex) { - examinable.remove(occurrence) - } - } - return examinable - } - - /// A node and everything reachable from it through parent links, as the places in `nodes` they occupy, - /// so reported depths are not needed. Visited is keyed on the occurrence and seeded with the root: a - /// producer that repeats an index deeper in the tree closes a loop this walk would follow forever, and - /// the classification runs outside the runner's bounded main-thread work, so no timeout would catch the - /// spin. The golden table's cycle case is pinned on exactly that shape, in both languages. - private static func subtreePositions( - _ rootPosition: Int, - in nodes: [PresentedSurfaceNode], - byParentIndex positionsByParentIndex: [Int: [Int]] - ) -> [Int] { - var collected: [Int] = [rootPosition] - var visited: Set = [rootPosition] - var walked = 0 - while walked < collected.count { - for position in positionsByParentIndex[nodes[collected[walked]].index] ?? [] where visited.insert(position).inserted { - collected.append(position) - } - walked += 1 - } - return collected - } - - private static func presentationContainers( - of window: PresentedSurfaceNode, - in nodes: [PresentedSurfaceNode] - ) -> [PresentedSurfaceNode] { - children(of: window, in: nodes).filter { - $0.className.map(normalizeClassName) == normalizeClassName(presentationContainerClass) - } - } - - private static func children( - of parent: PresentedSurfaceNode, - in nodes: [PresentedSurfaceNode] - ) -> [PresentedSurfaceNode] { - nodes.filter { $0.parentIndex == parent.index } - } } diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/SnapshotPresentedSurfaceTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/SnapshotPresentedSurfaceTests.swift index e5360f4090..4fac89a524 100644 --- a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/SnapshotPresentedSurfaceTests.swift +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/SnapshotPresentedSurfaceTests.swift @@ -2,18 +2,11 @@ import AgentDeviceSnapshotPresentation import Foundation import XCTest -/// Cross-language parity for the presented-surface rule (#3565): the golden table -/// `contracts/fixtures/ios-presented-surface.json` is the source of truth, and this test runs every case -/// in it through `SnapshotPresentedSurface.classify` twice — once with the UIKit class names the table -/// spells, once with every class name stripped, which is what a capture from a producer that reports no -/// class names looks like. The stripped reading is the one an XCTest runner tree has, and it is what the -/// table's `classBlindExpected` states. +/// Cross-language parity for the presented-surface rule (#3565): every case in +/// `contracts/fixtures/ios-presented-surface.json` runs through the runner entry point, which reads no +/// class names, so each must answer the table's `classBlindExpected`. final class SnapshotPresentedSurfaceTests: XCTestCase { private struct Vocabulary: Decodable { - let presentationContainerClass: String - let dimmingViewClass: String - let alertRootClassPrefix: String - let systemWindowClasses: [String] let presentingElementTypes: [PresentingElementType] } @@ -26,13 +19,11 @@ final class SnapshotPresentedSurfaceTests: XCTestCase { let index: Int let parentIndex: Int? let type: String - let role: String? } private struct FixtureCase: Decodable { let name: String let truncated: Bool? - let expected: String let classBlindExpected: String let nodes: [Node] } @@ -49,105 +40,58 @@ final class SnapshotPresentedSurfaceTests: XCTestCase { func testVocabularyMatchesTheGoldenTable() throws { let vocabulary = try readFixture().vocabulary - XCTAssertEqual(vocabulary.presentationContainerClass, SnapshotPresentedSurface.presentationContainerClass) - XCTAssertEqual(vocabulary.dimmingViewClass, SnapshotPresentedSurface.dimmingViewClass) - XCTAssertEqual(vocabulary.alertRootClassPrefix, SnapshotPresentedSurface.alertRootClassPrefix) - XCTAssertEqual(Set(vocabulary.systemWindowClasses), SnapshotPresentedSurface.systemWindowClasses) XCTAssertEqual( Dictionary(uniqueKeysWithValues: vocabulary.presentingElementTypes.map { ($0.name, $0.elementType) }), SnapshotPresentedSurface.presentingElementTypes ) } - func testEveryCaseClassifiesAsTheGoldenTableStates() throws { - for testCase in try readFixture().cases { - let nodes = testCase.nodes.map { - PresentedSurfaceNode(index: $0.index, parentIndex: $0.parentIndex, elementType: $0.type, className: $0.role) - } - let completeness = completeness(of: testCase) + func testEveryCaseClassifiesAsTheGoldenTableStatesForAClassBlindProducer() throws { + let cases = try readFixture().cases + XCTAssertFalse(cases.isEmpty) + for testCase in cases { XCTAssertEqual( - SnapshotPresentedSurface.classify(nodes: nodes, completeness: completeness), - presentedSurface(testCase.expected), - "\(testCase.name): class-aware reading" + SnapshotPresentedSurface.classify(acquisition(of: testCase)), + try XCTUnwrap(PresentedSurface(rawValue: testCase.classBlindExpected)), + testCase.name ) - let classBlind = nodes.map { - PresentedSurfaceNode(index: $0.index, parentIndex: $0.parentIndex, elementType: $0.elementType, className: nil) - } - XCTAssertEqual( - SnapshotPresentedSurface.classify(nodes: classBlind, completeness: completeness), - presentedSurface(testCase.classBlindExpected), - "\(testCase.name): class-blind reading" - ) - } - } - - /// The producer's own completeness claim, which the table states as `truncated`: a null is a producer - /// that observes nothing about truncation, and cannot be read as "complete". - private func completeness(of testCase: FixtureCase) -> PresentedSurfaceCompleteness { - guard let truncated = testCase.truncated else { return .unstated } - return truncated ? .capped : .complete - } - - private func presentedSurface(_ raw: String) -> PresentedSurface { - guard let value = PresentedSurface(rawValue: raw) else { - XCTFail("unknown expected state in the golden table: \(raw)") - return .unknown } - return value } - /// Completeness decides the absence half of the rule, and only a class-aware tree can reach that half: - /// the branch needs the window structure a UIKit class name states. So this is where the producer's - /// claim is pinned, on the table's ordinary screen — `absent` when the producer says the capture is - /// whole, `unknown` once it reports a cap or never looked. - func testCompletenessDecidesOnlyTheAbsenceHalf() throws { - let ordinary = try XCTUnwrap(try readFixture().cases.first { $0.name == "home, one container per window" }) - let nodes = ordinary.nodes.map { - PresentedSurfaceNode(index: $0.index, parentIndex: $0.parentIndex, elementType: $0.type, className: $0.role) - } - XCTAssertEqual(SnapshotPresentedSurface.classify(nodes: nodes, completeness: .complete), .absent) - XCTAssertEqual(SnapshotPresentedSurface.classify(nodes: nodes, completeness: .capped), .unknown) - XCTAssertEqual(SnapshotPresentedSurface.classify(nodes: nodes, completeness: .unstated), .unknown) - } - - /// The runner entry point takes an acquisition, and `RawAXNode` carries no UIKit class name, so every - /// reading through it is class-blind and can never earn `absent` — that is the coverage gap the fact - /// discloses, not a case to be coaxed out with a different completeness flag. What this pins is that the - /// producer's own positive evidence still answers through it, cap or no cap. - func testAcquisitionEntryAnswersOnPositiveEvidenceAlone() throws { + func testACapDoesNotDefeatPositiveEvidence() throws { let alert = try XCTUnwrap( try readFixture().cases.first { $0.name == "the same alert through a producer whose name table stops short" } ) - func acquisition(truncated: Bool) -> SnapshotAcquisition { - SnapshotAcquisition( - hint: SnapshotPresentation.captureHint( - for: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: true) - ), - nodes: alert.nodes.map { - RawAXNode( - index: $0.index, - type: $0.type, - label: nil, - identifier: nil, - value: nil, - rect: SnapshotRect(x: 0, y: 0, width: 10, height: 10), - enabled: true, - focused: nil, - selected: nil, - hittable: nil, - depth: 0, - parentIndex: $0.parentIndex, - hiddenContentAbove: nil, - hiddenContentBelow: nil - ) - }, - truncated: truncated, - effectiveDepth: nil, - viewport: .reported(box: CGRect(x: 0, y: 0, width: 100, height: 200)) - ) - } + XCTAssertEqual(SnapshotPresentedSurface.classify(acquisition(of: alert, truncated: false)), .presented) + XCTAssertEqual(SnapshotPresentedSurface.classify(acquisition(of: alert, truncated: true)), .presented) + } - XCTAssertEqual(SnapshotPresentedSurface.classify(acquisition(truncated: false)), .presented) - XCTAssertEqual(SnapshotPresentedSurface.classify(acquisition(truncated: true)), .presented) + private func acquisition(of testCase: FixtureCase, truncated: Bool? = nil) -> SnapshotAcquisition { + SnapshotAcquisition( + hint: SnapshotPresentation.captureHint( + for: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: true) + ), + nodes: testCase.nodes.map { + RawAXNode( + index: $0.index, + type: $0.type, + label: nil, + identifier: nil, + value: nil, + rect: SnapshotRect(x: 0, y: 0, width: 10, height: 10), + enabled: true, + focused: nil, + selected: nil, + hittable: nil, + depth: 0, + parentIndex: $0.parentIndex, + hiddenContentAbove: nil, + hiddenContentBelow: nil + ) + }, + truncated: truncated ?? testCase.truncated ?? false, + effectiveDepth: nil, + viewport: .reported(box: CGRect(x: 0, y: 0, width: 100, height: 200)) + ) } } diff --git a/contracts/fixtures/ios-presented-surface.json b/contracts/fixtures/ios-presented-surface.json index c49844c2d4..6a5d4c6369 100644 --- a/contracts/fixtures/ios-presented-surface.json +++ b/contracts/fixtures/ios-presented-surface.json @@ -1,5 +1,5 @@ { - "description": "The iOS rule that decides whether a capture read a modal presentation over the app's own content (issue #3565), and the tree shapes it is pinned on. Source of truth shared by the TypeScript classifier (collectPresentedSurface in packages/capture-kit/src/ios-snapshot-engine/engine.ts) and the Swift one (SnapshotPresentedSurface in apple/snapshot-presentation); a change here must keep both parity tests green, and each side mirrors the vocabulary below by hand the way contracts/fixtures/ios-system-surface-hosts.json does. Every case states the answer for a capture whose producer reports UIKit class names (`expected`) and for the same tree read by a producer that reports none (`classBlindExpected`) — both tests run both readings. `presented` comes only from positive evidence: a dimming view, an alert-root class, or an element type that names a presentation itself. `absent` is the claim that every window the capture read showed only its own content, so it needs class facts, a completeness statement, one content window, and one container in it; anything else answers `unknown`. Each case's `note` says whether the shape was read from a device or is anticipated, and the anticipated ones are why `unknown` exists.", + "description": "The iOS rule that decides whether a capture read a modal presentation over the app's own content (issue #3565), and the tree shapes it is pinned on. Source of truth for the TypeScript classifier (collectPresentedSurface in packages/capture-kit/src/ios-snapshot-engine/geometry-policy.ts) and the Swift runner classifier (SnapshotPresentedSurface in apple/snapshot-presentation); a change here must keep both parity tests green, and each side mirrors the vocabulary it reads by hand the way contracts/fixtures/ios-system-surface-hosts.json does. Every case states the answer for a capture whose producer reports UIKit class names (`expected`) and for the same tree read by a producer that reports none (`classBlindExpected`). The TypeScript test runs both readings; the Swift runner reports no class names, so it implements and checks only `classBlindExpected` and mirrors only presentingElementTypes. `presented` comes only from positive evidence: a dimming view, an alert-root class, or an element type that names a presentation itself. `absent` is the claim that every window the capture read showed only its own content, so it needs class facts, a completeness statement, one content window, and one container in it; anything else answers `unknown`. Each case's `note` says whether the shape was read from a device or is anticipated, and the anticipated ones are why `unknown` exists.", "vocabulary": { "presentationContainerClass": "UITransitionView", "presentationContainerNote": "UIKit appends each modal presentation as a later sibling of the container it presents over, directly under the window, and presents the app's own content inside one too. A window carrying more than one container therefore holds a presentation of some kind; one container is just the window's content.", @@ -16,7 +16,7 @@ { "name": "Dialog", "elementType": 8 } ], "presentingElementTypesNote": "Element types that name a presentation wherever they appear, keyed by the element-type numbering every iOS producer reads. A producer whose name table stops short of a name publishes `Element()` for the same node, so both spellings count and both are derived from these numbers rather than listed twice. The number must be written bare: `Element(+7)` is not a spelling of Alert, because a lenient numeric parse would let each language's integer parser decide the answer.", - "classNameMatchingNote": "Class names are matched after normalization, not byte for byte: trim, drop every case-insensitive `XCUIElementType`, drop a leading `AX`, lowercase, then keep only what follows the last `.` or `/`. That is `normalizeType` in packages/contracts/src/snapshot-text.ts, which the occlusion cut already matches UIKit classes through, and the Swift rule mirrors it. Producers spell one class differently — the bridge prefixes it with its module, the runner prefixes roles with AX — so a reader that compared raw bytes would answer a different question about the same capture than one that normalized. presentationContainerClass, dimmingViewClass, alertRootClassPrefix and systemWindowClasses are all read through it, so the spellings below name those classes however a producer writes them.", + "classNameMatchingNote": "Class names are matched after normalization, not byte for byte: trim, drop every case-insensitive `XCUIElementType`, drop a leading `AX`, lowercase, then keep only what follows the last `.` or `/`. That is `normalizeType` in packages/contracts/src/snapshot-text.ts, which the occlusion cut already matches UIKit classes through. One class can be spelled differently — with a module prefix, or with the `AX` prefix an accessibility role carries — so a reader that compared raw bytes would answer a different question about the same capture than one that normalized. presentationContainerClass, dimmingViewClass, alertRootClassPrefix and systemWindowClasses are all read through it, so the spellings below name those classes however a producer writes them.", "absentRequirementsNote": "absent additionally requires: `truncated` stated false (a producer that never observed completeness proves nothing about what it may have capped), a class name on every node (a producer that reports none can show no window structure), exactly one window outside systemWindowClasses (a second is a presentation UIKit hosts in its own window, or an overlay window, and the tree cannot tell those apart), and exactly one presentation container in that window (more than one is a full-screen or custom presentation, which this rule has no positive read on, and none means there is no container structure to read), and an index that identifies each node once (window structure is read through parent links, which cannot tell two nodes sharing a number apart). Positive evidence is exempt from that last requirement because it is keyed on the node occurrence it was read off, not on its number: a repeated index costs this rule the answer it declines to make, never a false absent." }, "cases": [ @@ -273,7 +273,7 @@ }, { "name": "a producer whose repeated index closes a cycle", - "note": "Malformed producer output, not read from a device: under the keyboard host window, a node repeats the index of an ancestor of itself, so following parent links walks that loop forever. Both languages have to terminate on it: the Swift classification runs outside the runner's bounded main-thread work, so no timeout would catch a spin there. The repeated index is also the answer: once two nodes share one, parent links no longer say which node is which, so the tree cannot show that every window holds only its own content.", + "note": "Malformed producer output, not read from a device: under the keyboard host window, a node repeats the index of an ancestor of itself, so following parent links walks that loop forever. The classifier has to terminate on it. The repeated index is also the answer: once two nodes share one, parent links no longer say which node is which, so the tree cannot show that every window holds only its own content.", "truncated": false, "expected": "unknown", "classBlindExpected": "unknown", @@ -324,7 +324,7 @@ }, { "name": "a page sheet whose classes are spelled with module and AX prefixes", - "note": "Anticipated producer output, not read from a device: a page-sheet capture in which every UIKit class is written a little differently from the way this table names it — the app window carries its module prefix, the dimming view carries the AX prefix the runner adds, and the keyboard host window is lowercased. Normalization is what lets this read as presented with two containers in the app window and that host window excluded; a rule comparing class names byte for byte would call the same tree undecided, and the two languages would then disagree about one capture.", + "note": "Anticipated producer output, not read from a device: a page-sheet capture in which every UIKit class is written a little differently from the way this table names it — the app window carries its module prefix, the dimming view carries an accessibility role's `AX` prefix, and the keyboard host window is lowercased. Normalization is what lets this read as presented with two containers in the app window and that host window excluded; a rule comparing class names byte for byte would call the same tree undecided.", "truncated": false, "expected": "presented", "classBlindExpected": "unknown", diff --git a/packages/capture-kit/src/ios-snapshot-engine/geometry-policy-presented-surface.test.ts b/packages/capture-kit/src/ios-snapshot-engine/geometry-policy-presented-surface.test.ts index cea22b0c2b..daf15bbd8a 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/geometry-policy-presented-surface.test.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/geometry-policy-presented-surface.test.ts @@ -55,9 +55,8 @@ function readFixture(): Fixture { return JSON.parse(fs.readFileSync(FIXTURE_PATH, 'utf8')) as Fixture; } -// Cross-language parity: the classifier must mirror the canonical table the Swift -// SnapshotPresentedSurface also reads, and the vocabulary it mirrors by hand. A drift on either side -// fails here or in the Swift test. +// The classifier must mirror the canonical table and the vocabulary it mirrors by hand. The Swift +// SnapshotPresentedSurface checks the same table's `classBlindExpected` column. test('the classifier mirrors the canonical vocabulary exactly', () => { const vocabulary = readFixture().vocabulary; expect(vocabulary.presentationContainerClass).toBe(PRESENTATION_CONTAINER_CLASS); diff --git a/packages/capture-kit/src/ios-snapshot-engine/geometry-policy.ts b/packages/capture-kit/src/ios-snapshot-engine/geometry-policy.ts index 55b9d5bc37..cb0b61251c 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/geometry-policy.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/geometry-policy.ts @@ -14,7 +14,7 @@ const NEGLIGIBLE_DECORATION_TOLERANCE = 1; * view as the presentation that dims everything before it, and the presented-surface rule reads a window * holding MORE than one of them as a capture it cannot call un-presented. A UIKit version that stopped * using this class would degrade one reader to `unknown` and the other to no cut at all, and both - * failures are silent, so the pairing lives in the golden table's vocabulary and in both parity tests. + * failures are silent, so the pairing lives in the golden table's vocabulary and its parity test. */ export const PRESENTATION_CONTAINER_CLASS = 'UITransitionView'; @@ -385,8 +385,8 @@ export type PresentedSurfaceCompleteness = 'complete' | 'capped' | 'unstated'; * `presented` or `unknown`, never `absent`. * * The cross-language rule and the tree shapes it is pinned on are the golden table - * `contracts/fixtures/ios-presented-surface.json`; the Swift `SnapshotPresentedSurface` mirrors this - * vocabulary and each side has a parity test against that table. + * `contracts/fixtures/ios-presented-surface.json`. The Swift `SnapshotPresentedSurface` implements only + * the class-blind reading (`classBlindExpected`), and each side has a parity test against that table. */ export function collectPresentedSurface( nodes: readonly RawSnapshotNode[], @@ -462,9 +462,7 @@ function isSystemWindow(node: RawSnapshotNode): boolean { * support a claim about every window it read, and such a tree is answered here rather than walked * further. The engine's own fold refuses this tree outright, so the regular route never reaches here with * one; the guard is what keeps the answer honest when the classifier is called on a tree that has not - * passed through it — which is the Apple runner's route, where the classification runs on the acquisition - * and no graph validation happens first. Both languages answer `unknown` for the same reason, which is - * the parity the golden table pins. + * passed through it. */ function identifiesEachNodeOnce(nodes: readonly RawSnapshotNode[]): boolean { return new Set(nodes.map((node) => node.index)).size === nodes.length; @@ -524,9 +522,8 @@ function positionsOutsideSystemWindows( /** * A node and everything reachable from it through parent links, as the places in `nodes` they occupy. * Reported depths are not needed. Visited is keyed on the occurrence and seeded with the root: a producer - * that closes a loop deeper in the tree is one this walk would follow forever, and the classification runs - * outside the Apple runner's bounded main-thread work, where no timeout would catch the spin. The golden - * table's cycle case is pinned on exactly that shape, in both languages. + * that closes a loop deeper in the tree is one this walk would follow forever. The golden table's cycle + * case is pinned on exactly that shape. */ function subtreePositions( rootPosition: number, diff --git a/scripts/__tests__/eager-closure-budgets.ts b/scripts/__tests__/eager-closure-budgets.ts index ae16f53cbf..01a719f264 100644 --- a/scripts/__tests__/eager-closure-budgets.ts +++ b/scripts/__tests__/eager-closure-budgets.ts @@ -132,14 +132,7 @@ export const NEW_ENTRY_CEILINGS: Readonly> = Objec */ export const APPROVED_OVER_CEILING: Readonly< Record -> = Object.freeze({ - 'packages/maestro/src/replay-backend/replay-maestro-backend.ts': { - issue: '#3377', - reason: - 'The Maestro replay backend plugin IS the engine behind `replay --maestro`; the registry reaches it only through a function-scoped import(), and no startup hub evaluates it (cli-startup-import-closure pins that).', - owner: 'thymikee', - }, -}); +> = Object.freeze({}); /** The category is a function of the path, never a hand-written column. */ export function entryCategoryOf(entryFile: string): EntryCategory {