diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index 0562d2fd14..596c9a39ca 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -86,11 +86,20 @@ struct SnapshotBackendCapture { /// tree tier reads the keyboard, so only that tier has one; the daemon reads a missing band as "this /// producer could not measure", which is exactly what the query-sweep and private-AX tiers did. var keyboardBand: KeyboardBandFactPayload? = nil + /// Whether this capture read a modal presentation over the app (#3565), classified on the + /// acquisition this capture presents. A tier that classified nothing carries nothing, and the host + /// reads that as no evidence rather than as absence. + var presentedSurface: PresentedSurface? = nil } extension RunnerTests { + /// Assembles what one tier answered, from the tree it presented and the acquisition it read. The + /// presented-surface fact is classified on the acquisition rather than the presentation, because + /// interactive filtering, depth and scope are all cuts into what the tier actually saw (#3565): a + /// capture of a presented screen that kept four nodes still read the presentation. static func makeSnapshotBackendCapture( - from result: AgentDeviceSnapshotPresentation.SnapshotPresentationResult + from result: AgentDeviceSnapshotPresentation.SnapshotPresentationResult, + acquired acquisition: SnapshotAcquisition ) -> SnapshotBackendCapture { SnapshotBackendCapture( payload: DataPayload(nodes: result.nodes, truncated: result.truncated), @@ -98,7 +107,8 @@ extension RunnerTests { customActions: result.customActions, qualityPayload: result.qualityNodes.map { DataPayload(nodes: $0, truncated: result.truncated) - } + }, + presentedSurface: SnapshotPresentedSurface.classify(acquisition) ) } @@ -576,7 +586,7 @@ extension RunnerTests { acquired: normalizedAcquisition.hint.projection ) } - return Self.makeSnapshotBackendCapture(from: result) + return Self.makeSnapshotBackendCapture(from: result, acquired: normalizedAcquisition) } } catch let failure as SnapshotPresentationFailure { return SnapshotBackendAttempt( @@ -754,6 +764,7 @@ extension RunnerTests { return SnapshotQualityPayload(nodes: nodes, truncated: quality.truncated == true) }, snapshotQuality: quality, + presentedSurface: capture.presentedSurface, keyboard: capture.keyboardBand, runnerFatal: payload.runnerFatal, runnerFatalReason: payload.runnerFatalReason diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift index 806db679f1..d8ce8065fb 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift @@ -269,6 +269,83 @@ extension RunnerTests { XCTAssertNil(payload.qualityPayload?.scope) } + /// The classification has to happen on the acquisition a tier read, not on the presentation the tier + /// returns, and survive onto the response envelope. This drives the production assembly function with a + /// tree that keeps only what the runner's own AX tier publishes — no UIKit class names, an element type + /// the runner's name table cannot spell — and asks for a capture that dropped every node the acquisition + /// held. Deleting the classification, or reading the presented tree instead of the acquired one, fails + /// it (#3565). + func testSnapshotCaptureAssemblyClassifiesTheAcquisitionItPresented() throws { + let options = PresentationOptions(interactiveOnly: true, depth: nil, scope: nil, raw: false) + let evidenceIndex = 2 + let acquisition = SnapshotAcquisition( + hint: SnapshotPresentation.captureHint(for: options), + nodes: [ + planTestRawNode(index: 0, type: "Application", parentIndex: nil), + planTestRawNode(index: 1, type: "Window", parentIndex: 0), + planTestRawNode(index: evidenceIndex, type: "Element(7)", parentIndex: 1), + ], + truncated: false, + effectiveDepth: nil, + viewport: .reported(box: CGRect(x: 0, y: 0, width: 402, height: 874)) + ) + let presented = try SnapshotPresentation.presentRegular(acquisition, options: options) + // The premise the assertion below turns on, pinned rather than assumed: this is the node the + // evidence sits on, and the tier's own presentation does not carry it. Were it retained, the two + // trees would agree and the next line would stop proving which one was read. + XCTAssertFalse( + presented.nodes.contains { $0.index == evidenceIndex }, + "the presentation has to drop the evidence node, or this test cannot tell the two trees apart" + ) + + let capture = Self.makeSnapshotBackendCapture(from: presented, acquired: acquisition) + XCTAssertEqual(capture.presentedSurface, .presented, "positive evidence below the presented cut") + + // The same presentation assembled against an acquisition that holds no evidence must answer from + // the acquisition. If the classifier ever read the presented tree instead, this pair collapses to + // the same answer and the assertion above stops proving anything. + let cleared = acquisition.replacingNodes([ + planTestRawNode(index: 0, type: "Application", parentIndex: nil) + ]) + XCTAssertEqual( + Self.makeSnapshotBackendCapture(from: presented, acquired: cleared).presentedSurface, + .unknown, + "the fact follows the acquisition, not the presentation it produced" + ) + + let payload = stampedSnapshotPayload(capture, backend: .recursiveTree, state: .healthy, reason: nil) + XCTAssertEqual(payload.presentedSurface, .presented) + // A tier that assembled no fact publishes nothing: silence is how the host reads "this producer + // published no fact", and it must not be reachable by a default. + XCTAssertNil( + stampedSnapshotPayload( + SnapshotBackendCapture(payload: capture.payload, effectiveDepth: nil), + backend: .recursiveTree, + state: .healthy, + reason: nil + ).presentedSurface + ) + } + + private func planTestRawNode(index: Int, type: String, parentIndex: Int?) -> RawAXNode { + RawAXNode( + index: index, + type: 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: parentIndex == nil ? 0 : 1, + parentIndex: parentIndex, + hiddenContentAbove: nil, + hiddenContentBelow: nil + ) + } + func testDirectPresentationDoesNotClaimPlanTiming() { let options = PresentationOptions( interactiveOnly: false, @@ -276,17 +353,15 @@ extension RunnerTests { scope: nil, raw: true ) - let result = SnapshotPresentation.presentRaw( - SnapshotAcquisition( - hint: SnapshotPresentation.captureHint(for: options), - nodes: [], - truncated: false, - effectiveDepth: nil, - viewport: .reported(box: CGRect(x: 0, y: 0, width: 402, height: 874)) - ), - options: options + let acquisition = SnapshotAcquisition( + hint: SnapshotPresentation.captureHint(for: options), + nodes: [], + truncated: false, + effectiveDepth: nil, + viewport: .reported(box: CGRect(x: 0, y: 0, width: 402, height: 874)) ) - let capture = Self.makeSnapshotBackendCapture(from: result) + let result = SnapshotPresentation.presentRaw(acquisition, options: options) + let capture = Self.makeSnapshotBackendCapture(from: result, acquired: acquisition) let payload = stampedSnapshotPayload( capture, diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentedSurface.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentedSurface.swift new file mode 100644 index 0000000000..abf2b53c15 --- /dev/null +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentedSurface.swift @@ -0,0 +1,260 @@ +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. +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. + public static let presentingElementTypes: [String: Int] = [ + "Sheet": 5, + "Drawer": 6, + "Alert": 7, + "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()) + } + + private static func isPresentingElementType(_ elementType: String) -> Bool { + if presentingElementTypes[elementType] != nil { return true } + guard let rawValue = unnamedElementTypeRawValue(elementType) else { return false } + 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. + private static func unnamedElementTypeRawValue(_ elementType: String) -> Int? { + guard elementType.hasPrefix(unnamedElementTypePrefix), elementType.hasSuffix(")") else { return nil } + let digits = elementType.dropFirst(unnamedElementTypePrefix.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 new file mode 100644 index 0000000000..e5360f4090 --- /dev/null +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/SnapshotPresentedSurfaceTests.swift @@ -0,0 +1,153 @@ +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. +final class SnapshotPresentedSurfaceTests: XCTestCase { + private struct Vocabulary: Decodable { + let presentationContainerClass: String + let dimmingViewClass: String + let alertRootClassPrefix: String + let systemWindowClasses: [String] + let presentingElementTypes: [PresentingElementType] + } + + private struct PresentingElementType: Decodable { + let name: String + let elementType: Int + } + + private struct Node: Decodable { + 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] + } + + private struct Fixture: Decodable { + let vocabulary: Vocabulary + let cases: [FixtureCase] + } + + private func readFixture() throws -> Fixture { + let url = contractsFixtureURL("ios-presented-surface.json") + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + 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) + XCTAssertEqual( + SnapshotPresentedSurface.classify(nodes: nodes, completeness: completeness), + presentedSurface(testCase.expected), + "\(testCase.name): class-aware reading" + ) + 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 { + 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(truncated: false)), .presented) + XCTAssertEqual(SnapshotPresentedSurface.classify(acquisition(truncated: true)), .presented) + } +} diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 1e864a245c..01d48f5ea0 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -262,6 +262,8 @@ Domain client methods: `client.capture.snapshot()` carries an optional `viewport: { width, height }` beside `nodes`: the box those rects are measured in, in the same coordinate space and orientation. Scale and clip against it instead of inferring the screen from the largest rect. When no box was measured, the field is absent rather than zero; see [Coordinates and viewport](/docs/snapshots#coordinates-and-viewport) for what each producer answers with. +It also carries an optional `presentedSurface: 'presented' | 'absent' | 'unknown'` on iOS captures: whether the capture saw a modal presentation over your app's content. `'absent'` is a reading that was earned, so it is safe to act on; `'unknown'` says the capture could not tell and is not `'absent'`. The field is absent on other platforms. See [Presented surface](/docs/snapshots#presented-surface). + `client.observability.events({ cursor, limit })` reads the session event timeline as paged JSON entries. Use `nextCursor` from the previous page to continue from the daemon-owned `events.ndjson` file without replaying already uploaded/displayed events. Cursors are absolute and survive the file's size rotation; a cursor older than the retained window rejects with `COMMAND_FAILED`, `details.reason: "EVENT_LOG_CURSOR_EXPIRED"`, and `details.earliestCursor` to resume from. The timeline leaves out user-entered content such as typed text, selector values, and payloads; see [Sessions](/docs/sessions#find-a-sessions-logs-and-artifacts) for what it keeps. diff --git a/website/docs/docs/snapshots.md b/website/docs/docs/snapshots.md index 60d5590471..66819d800e 100644 --- a/website/docs/docs/snapshots.md +++ b/website/docs/docs/snapshots.md @@ -216,6 +216,31 @@ snapshot was taken on instead of guessing it from the largest rect. For Android pixel units and density, see [Android node metadata](#android-node-metadata). +## Presented surface + +`snapshot --json` reports `presentedSurface` — whether the capture saw a modal presentation over your +app's own content: `"presented"`, `"absent"`, or `"unknown"`. Read it instead of counting windows in +the tree to work out whether a sheet or alert was up. + +- `"presented"` means the capture saw the presentation directly — a dimming view, an alert's own + view classes, or an element typed as a sheet, drawer, alert, or dialog. +- `"absent"` is an earned answer, not a default. The capture checked every window it read and each + one held only its own content. This is the answer you can act on, for example to confirm a tap + dismissed a sheet. +- `"unknown"` means the capture could not tell, and it is not a hedge — it says the capture lacks the + evidence absence needs. Absence is only reachable from a capture that reads UIKit class names, so a + screen served by a backend that publishes none answers `"unknown"` even when nothing is presented. + That is the private accessibility backend (`snapshotQuality.backend: "private-ax"`, which `--actions` + asks for) and an Appium or Limrun tree. Never treat `"unknown"` as `"absent"`. +- A physical iOS device has one capture backend, and it publishes no class names, so there the answer + is `"presented"` or `"unknown"` and never `"absent"`. +- The field is absent entirely when the platform publishes no reading at all: Android, web, Linux, + and macOS captures. + +The answer describes the screen, not the view of it you asked for, so `--raw`, `-i`, `-d`, and +`--scope` all report the same value for one screen. Use `-i` or a `--scope` to keep the output small +and still trust the answer. + ## iOS capture behavior iOS snapshots use XCTest on simulators and physical devices. You can't pick the capture backend. `--raw` switches between two strategies, and each strategy