diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj b/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj index d1f1ad5542..506ee028df 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj @@ -113,6 +113,7 @@ ); name = AgentDeviceRunnerUITests; packageProductDependencies = ( + A1B2C3D4E5F60718293A4B5D /* AgentDeviceSnapshotPresentation */, ); productName = AgentDeviceRunnerUITests; productReference = 20EA2EDD2F2CFC7C001CF0EF /* AgentDeviceRunnerUITests.xctest */; @@ -146,6 +147,9 @@ ); mainGroup = 20EA2EBD2F2CFC7B001CF0EF; minimizedProjectReferenceProxies = 1; + packageReferences = ( + A1B2C3D4E5F60718293A4B5C /* XCLocalSwiftPackageReference "snapshot-presentation" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 20EA2EC72F2CFC7B001CF0EF /* Products */; projectDirPath = ""; @@ -157,6 +161,21 @@ }; /* End PBXProject section */ +/* Begin XCLocalSwiftPackageReference section */ + A1B2C3D4E5F60718293A4B5C /* XCLocalSwiftPackageReference "snapshot-presentation" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../snapshot-presentation; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A1B2C3D4E5F60718293A4B5D /* AgentDeviceSnapshotPresentation */ = { + isa = XCSwiftPackageProductDependency; + package = A1B2C3D4E5F60718293A4B5C /* XCLocalSwiftPackageReference "snapshot-presentation" */; + productName = AgentDeviceSnapshotPresentation; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin PBXResourcesBuildPhase section */ 20EA2EC42F2CFC7B001CF0EF /* Resources */ = { isa = PBXResourcesBuildPhase; diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSnapshotScopePolicy.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSnapshotScopePolicy.swift index ba99ea5ba9..1f7c257a1e 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSnapshotScopePolicy.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSnapshotScopePolicy.swift @@ -1,61 +1,5 @@ import Foundation - -enum SnapshotScopeSelection: Equatable { - case unscoped - case matched(Int) - case missing -} - -/// Cross-runtime snapshot scope specification. -/// -/// A non-empty scope selects the first node in presentation preorder whose label, identifier, or -/// value contains the trimmed query case-insensitively and whose subtree contributes to the -/// requested projection. Missing matches publish an empty projection. -enum SnapshotScopePolicy { - static func select( - fromPreorder nodes: [Node], - scope: String?, - depth: (Node) -> Int, - semanticValues: (Node) -> [String?], - subtreeContributes: (Range) -> Bool - ) -> SnapshotScopeSelection { - guard let query = normalized(scope) else { return .unscoped } - for (index, node) in nodes.enumerated() { - guard semanticValues(node).contains(where: { value in - value?.lowercased().contains(query) == true - }) else { continue } - let range = subtreeRange(from: index, in: nodes, depth: depth) - if subtreeContributes(range) { - return .matched(index) - } - } - return .missing - } - - static func isActive(_ scope: String?) -> Bool { - normalized(scope) != nil - } - - private static func normalized(_ scope: String?) -> String? { - guard let query = scope?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty else { - return nil - } - return query.lowercased() - } - - static func subtreeRange( - from start: Int, - in nodes: [Node], - depth: (Node) -> Int - ) -> Range { - let rootDepth = depth(nodes[start]) - var end = start + 1 - while end < nodes.count, depth(nodes[end]) > rootDepth { - end += 1 - } - return start.. Bool { - guard enabled, !frame.isNull, !frame.isEmpty else { return false } - if viewport.isInfinite { return true } - let center = CGPoint(x: frame.midX, y: frame.midY) - return viewport.contains(center) - } -} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index 91dad856fa..0d5d6c07ef 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -1,4 +1,5 @@ import XCTest +import AgentDeviceSnapshotPresentation // MARK: - Snapshot capture plans (ADR 0004) // @@ -30,21 +31,6 @@ struct SnapshotQuality: Codable { var timing: SnapshotCaptureTiming? = nil } -/// How much of the merged-element set the custom-action pass actually read. An -/// unread element renders exactly like one with no actions, so a partial pass -/// has to say so — otherwise absence reads as proof of absence. -struct SnapshotCustomActionCoverage: Codable { - let read: Int - let candidates: Int - /// Elements whose action list was clipped by the per-element output caps. A - /// clipped list looks complete, so it is disclosed on the same principle as - /// an unread element. - let truncated: Int - /// The pass stopped early because an earlier read is still hung. Distinct from - /// a budget stop: the remedy is waiting, not scrolling. - let blocked: Bool -} - enum SnapshotXCTestChannelPlanState: Equatable { case normal case deferredToIndependentBackend @@ -72,19 +58,26 @@ enum SnapshotCaptureTerminalPolicy { struct SnapshotBackendCapture { let payload: DataPayload - /// Set by the private AX backend when the ladder accepted a shallower depth than requested. let effectiveDepth: Int? - /// Set by the private AX backend when the capture asked for custom actions. var customActions: SnapshotCustomActionCoverage? = nil - /// Broad presentation used only by the quality classifier when a scope narrows publication. - /// A legitimate missing scope is an empty healthy projection, not backend failure evidence. var qualityPayload: DataPayload? = nil - /// Set by the capture plan after measuring acquisition and presentation separately. Direct - /// presentation fixtures do not claim a plan timing. var timing: SnapshotCaptureTiming? = nil } extension RunnerTests { + static func makeSnapshotBackendCapture( + from result: AgentDeviceSnapshotPresentation.SnapshotPresentationResult + ) -> SnapshotBackendCapture { + SnapshotBackendCapture( + payload: DataPayload(nodes: result.nodes, truncated: result.truncated), + effectiveDepth: result.effectiveDepth, + customActions: result.customActions, + qualityPayload: result.qualityNodes.map { + DataPayload(nodes: $0, truncated: result.truncated) + } + ) + } + static let sparseRecoveryTruncatedNodeThreshold = 8 /// Umbrella wall-clock budget for one capture plan. Individual backends bound themselves, /// but chained recovery tiers must never stack past the 30s main-thread watchdog: when the @@ -496,14 +489,14 @@ extension RunnerTests { let presented: SnapshotBackendCapture do { presented = try timer.measure(.presentation) { - guard let capture = try SnapshotPresentation.present(acquisition, options: options) else { + guard let result = try SnapshotPresentation.present(acquisition, options: options) else { throw Self.snapshotProjectionMismatchFailure( kind, requested: hint.projection, acquired: acquisition.hint.projection ) } - return capture + return Self.makeSnapshotBackendCapture(from: result) } } catch let failure as SnapshotPresentationFailure { return SnapshotBackendAttempt( @@ -891,7 +884,7 @@ extension RunnerTests { scope: nil, raw: true ) - let capture = SnapshotPresentation.presentRaw( + let result = SnapshotPresentation.presentRaw( SnapshotAcquisition( hint: SnapshotPresentation.captureHint(for: options), nodes: [], @@ -901,6 +894,7 @@ extension RunnerTests { ), options: options ) + let capture = Self.makeSnapshotBackendCapture(from: result) let payload = stampedSnapshotPayload( capture, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentation.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentation.swift deleted file mode 100644 index 09c5ce51fa..0000000000 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentation.swift +++ /dev/null @@ -1,421 +0,0 @@ -import Foundation - -enum SnapshotPresentation { - /// The only snapshot node shape accepted by response payload assembly. - /// - /// The encoded shape intentionally remains byte-for-byte compatible with the former - /// `SnapshotNode` wire model. Construction stays file-private to this presentation module: - /// acquisition backends can return `RawAXNode` values, but cannot recreate a presented node. - struct PresentedNode: Codable { - let index: Int - let type: String - let label: String? - let identifier: String? - let value: String? - let rect: SnapshotRect - let enabled: Bool - let focused: Bool? - let selected: Bool? - let hittable: Bool - let depth: Int - let parentIndex: Int? - let hiddenContentAbove: Bool? - let hiddenContentBelow: Bool? - let actions: [String]? - - fileprivate init(presenting raw: RawAXNode) { - self.init( - presenting: raw, - rect: raw.rect, - index: raw.index, - depth: raw.depth, - parentIndex: raw.parentIndex - ) - } - - fileprivate init(presenting node: SnapshotPresentationNode) { - self.init( - presenting: node.raw, - rect: node.effectiveRect, - index: node.raw.index, - depth: node.raw.depth, - parentIndex: node.raw.parentIndex - ) - } - - fileprivate init( - presenting raw: RawAXNode, - rect: SnapshotRect, - index: Int, - depth: Int, - parentIndex: Int? - ) { - self.index = index - type = raw.type - label = raw.label - identifier = raw.identifier - value = raw.value - self.rect = rect - enabled = raw.enabled - focused = raw.focused - selected = raw.selected - hittable = raw.hittable - self.depth = depth - self.parentIndex = parentIndex - hiddenContentAbove = raw.hiddenContentAbove - hiddenContentBelow = raw.hiddenContentBelow - actions = raw.actions - } - } - - private static let eligibleInteractiveTypes: Set = [ - "Button", - "Cell", - "CheckBox", - "CollectionView", - "Link", - "MenuItem", - "Picker", - "SearchField", - "SecureTextField", - "SegmentedControl", - "Slider", - "ScrollView", - "Stepper", - "Switch", - "TabBar", - "Table", - "TextField", - "TextView", - "WebView", - ] - - /// The capture plan's single presentation route for every snapshot backend. - /// - /// Refuses an acquisition captured for the other projection instead of presenting it under the - /// requested label: a backend that ignores `--raw` loses its tier and the plan advances, rather - /// than returning regular-projection membership called raw (#1797 D4). - static func present( - _ acquisition: SnapshotAcquisition, - options: PresentationOptions - ) throws -> SnapshotBackendCapture? { - let requested = captureHint(for: options) - guard acquisition.hint.projection == requested.projection else { - NSLog( - "AGENT_DEVICE_RUNNER_SNAPSHOT_PROJECTION_MISMATCH requested=%@ acquired=%@", - requested.projection.rawValue, - acquisition.hint.projection.rawValue - ) - return nil - } - switch requested.projection { - case .regular: - return try presentRegular(acquisition, options: options) - case .raw: - return presentRaw(acquisition, options: options) - } - } - - /// Visible projection: the clip fold (viewport ∩ scroll clip, ancestor cursor, scroll hints), - /// eligibility (an interactive type or non-empty semantic content, below the root carrier), and - /// scope. The only interpreter of what a screen currently shows -- no backend folds its own - /// visibility (#1797). Throws a typed `SnapshotPresentationFailure` if the folded result would - /// violate the regular projection's geometry or actionability contract. - static func presentRegular( - _ acquisition: SnapshotAcquisition, - options: PresentationOptions, - policy: SnapshotVisibilityFold.Policy = .platformDefault - ) throws -> SnapshotBackendCapture { - let folded = SnapshotVisibilityFold.fold( - acquisition.nodes, - viewport: acquisition.viewport, - interactiveOnly: options.interactiveOnly, - policy: policy - ) - try SnapshotPresentation.validateRegularInvariant( - folded, - viewport: acquisition.viewport, - policy: policy - ) - return project( - folded, - acquisition: acquisition, - options: options, - projection: .regular - ) - } - - /// Diagnostic projection: the acquired tree, normalized. Scope and depth apply when explicitly - /// requested; membership is never narrowed, so `interactive ⊆ regular ⊆ raw` holds for every - /// backend (ADR 0004's raw contract). - static func presentRaw( - _ acquisition: SnapshotAcquisition, - options: PresentationOptions - ) -> SnapshotBackendCapture { - project( - acquisition.nodes.map(SnapshotPresentationNode.reported), - acquisition: acquisition, - options: options, - projection: .raw - ) - } - - /// Derives the one acquisition-facing view of a request, so no backend re-reads - /// `PresentationOptions` and reaches its own conclusion about what to capture. - /// - /// Scope re-roots the presented tree and depth counts from that root, so a scoped request - /// narrows neither: acquire broad, select once in presentation. - static func captureHint(for options: PresentationOptions) -> CaptureHint { - let scoped = SnapshotScopePolicy.isActive(options.scope) - let projection: CaptureHint.Projection = options.raw ? .raw : .regular - return CaptureHint( - projection: projection, - depth: scoped || projection == .regular ? nil : options.depth, - regularPresentedDepth: scoped || projection == .raw ? nil : options.depth, - interactiveOnly: projection == .raw ? false : options.interactiveOnly, - customActions: options.customActions - ) - } - - /// Raw depth is an acquisition limit. Regular depth is a presentation limit, - /// so hierarchy-capable backends acquire through the structural-wrapper - /// frontier until this presented depth is complete. - static func shouldAcquireChildren( - for hint: CaptureHint, - rawDepth: Int, - regularPresentedDepth: Int - ) -> Bool { - if let rawLimit = hint.rawTraversalDepth { - return rawDepth < rawLimit - } - if let presentedLimit = hint.regularPresentedDepth { - return regularPresentedDepth < presentedLimit - } - return true - } - - /// Computes one regular-tree frontier transition for both acquisition and its tests. The fold - /// remains the sole visibility interpreter; this helper only carries its decision into the - /// presentation-owned depth budget and the next traversal state. - static func regularTraversalTransition( - for raw: RawAXNode, - parentPresentedDepth: Int, - parentTraversal: SnapshotVisibilityFold.TraversalState, - hint: CaptureHint, - rawDepth: Int, - viewport: CGRect, - hasChildren: Bool, - isDuplicate: Bool, - policy: SnapshotVisibilityFold.Policy = .platformDefault - ) -> ( - presentedDepth: Int, - traversal: SnapshotVisibilityFold.TraversalState, - shouldVisitChildren: Bool - ) { - guard hint.regularPresentedDepth != nil else { - return ( - parentPresentedDepth, - parentTraversal, - shouldAcquireChildren( - for: hint, - rawDepth: rawDepth, - regularPresentedDepth: parentPresentedDepth - ) - ) - } - - let visibility = SnapshotVisibilityFold.traversalDecision( - for: raw, - parent: parentTraversal, - viewport: viewport, - interactiveOnly: hint.interactiveOnly, - hasChildren: hasChildren, - policy: policy - ) - let presentedDepth = regularPresentedDepth( - for: raw, - parentPresentedDepth: parentPresentedDepth, - visibility: visibility - ) - let nextPresentedDepth = isDuplicate ? parentPresentedDepth : presentedDepth - let nextTraversal = isDuplicate ? parentTraversal : visibility.descendants - let descendantsMayBeVisible = isDuplicate - ? parentTraversal.descendantsMayBeVisible - : visibility.descendantsMayBeVisible - return ( - nextPresentedDepth, - nextTraversal, - shouldAcquireChildren( - for: hint, - rawDepth: rawDepth, - regularPresentedDepth: nextPresentedDepth - ) && descendantsMayBeVisible - ) - } - - /// Shared depth accounting for the acquisition frontier. The fold supplies the same visibility - /// decision used by regular presentation; this method adds only the presentation-owned semantic - /// eligibility predicate. - static func regularPresentedDepth( - for raw: RawAXNode, - parentPresentedDepth: Int, - visibility: SnapshotVisibilityFold.TraversalDecision - ) -> Int { - guard raw.parentIndex != nil else { return 0 } - return visibility.isIncluded && isEligibleForRegularPresentation(raw) - ? parentPresentedDepth + 1 - : parentPresentedDepth - } - - private static func project( - _ projectionNodes: [SnapshotPresentationNode], - acquisition: SnapshotAcquisition, - options: PresentationOptions, - projection: CaptureHint.Projection - ) -> SnapshotBackendCapture { - let scopedRawNodes = applyScope(to: projectionNodes, options: options, projection: projection) - let nodes = presentedNodes( - from: scopedRawNodes, - projection: projection, - maximumDepth: projection == .regular ? options.depth : nil - ) - let qualityPayload: DataPayload? = SnapshotScopePolicy.isActive(options.scope) - ? DataPayload( - nodes: presentedNodes(from: projectionNodes, projection: projection, maximumDepth: nil), - truncated: acquisition.truncated - ) - : nil - return SnapshotBackendCapture( - payload: DataPayload( - nodes: nodes, - truncated: acquisition.truncated - ), - effectiveDepth: acquisition.effectiveDepth, - customActions: acquisition.customActions, - qualityPayload: qualityPayload - ) - } - - /// Explicit carve-out for selector queries and system-modal reads that intentionally return one - /// already-resolved element instead of traversing a snapshot backend. - static func singleElementRead(_ node: RawAXNode) -> PresentedNode { - PresentedNode(presenting: node) - } - - private static func presentedNodes( - from rawNodes: [SnapshotPresentationNode], - projection: CaptureHint.Projection, - maximumDepth: Int? = nil - ) -> [PresentedNode] { - if projection == .raw { - return rawNodes.map { PresentedNode(presenting: $0) } - } - - var nodes: [PresentedNode] = [] - var nearestPresentedNodeByRawIndex: [Int: (index: Int, depth: Int)] = [:] - for node in rawNodes { - let raw = node.raw - let presentedParent = raw.parentIndex.flatMap { - nearestPresentedNodeByRawIndex[$0] - } - guard isEligibleForRegularPresentation(raw) else { - if let presentedParent { - nearestPresentedNodeByRawIndex[raw.index] = presentedParent - } - continue - } - - let presentedIndex = nodes.count - let presentedDepth = presentedParent.map { $0.depth + 1 } ?? 0 - if let maximumDepth, presentedDepth > maximumDepth { - if let presentedParent { - nearestPresentedNodeByRawIndex[raw.index] = presentedParent - } - continue - } - nearestPresentedNodeByRawIndex[raw.index] = (presentedIndex, presentedDepth) - nodes.append( - PresentedNode( - presenting: raw, - rect: node.effectiveRect, - index: presentedIndex, - depth: presentedDepth, - parentIndex: presentedParent?.index - ) - ) - } - return nodes - } - - private static func applyScope( - to rawNodes: [SnapshotPresentationNode], - options: PresentationOptions, - projection: CaptureHint.Projection - ) -> [SnapshotPresentationNode] { - switch SnapshotScopePolicy.select( - fromPreorder: rawNodes, - scope: options.scope, - depth: { $0.raw.depth }, - semanticValues: { [$0.raw.label, $0.raw.identifier, $0.raw.value] }, - subtreeContributes: { range in - projection == .raw - || rawNodes[range].contains { isEligibleForRegularPresentation($0.raw) } - } - ) { - case .unscoped: - return rawNodes - case .missing: - return [] - case .matched(let startIndex): - let startDepth = rawNodes[startIndex].raw.depth - let range = SnapshotScopePolicy.subtreeRange( - from: startIndex, - in: rawNodes, - depth: { $0.raw.depth } - ) - let maxDepth = options.depth ?? Int.max - let scopedNodes = projection == .raw - ? Array(rawNodes[range]).filter { $0.raw.depth - startDepth <= maxDepth } - : Array(rawNodes[range]) - return reindex(scopedNodes, depthOffset: startDepth) - } - } - - private static func reindex( - _ nodes: [SnapshotPresentationNode], - depthOffset: Int - ) -> [SnapshotPresentationNode] { - let indexMap = Dictionary( - uniqueKeysWithValues: nodes.enumerated().map { ($0.element.raw.index, $0.offset) }) - return nodes.enumerated().map { offset, node in - let raw = node.raw - return SnapshotPresentationNode( - raw: RawAXNode( - index: offset, - type: raw.type, - label: raw.label, - identifier: raw.identifier, - value: raw.value, - rect: raw.rect, - enabled: raw.enabled, - focused: raw.focused, - selected: raw.selected, - hittable: raw.hittable, - depth: max(0, raw.depth - depthOffset), - parentIndex: raw.parentIndex.flatMap { indexMap[$0] }, - hiddenContentAbove: raw.hiddenContentAbove, - hiddenContentBelow: raw.hiddenContentBelow, - actions: raw.actions - ), - effectiveRect: node.effectiveRect - ) - } - } - - private static func isEligibleForRegularPresentation(_ node: RawAXNode) -> Bool { - // The top-level carrier owns viewport geometry and must survive even for query-sweep's - // deliberately unlabeled synthetic Application node. - if node.parentIndex == nil { return true } - return eligibleInteractiveTypes.contains(node.type) || node.hasSemanticContent - } -} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentationGeometry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentationGeometry.swift deleted file mode 100644 index 50e8664258..0000000000 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentationGeometry.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation - -/// The typed carrier between acquisition facts and snapshot wire projection. -/// -/// `raw.rect` remains the backend-reported frame for runner-internal traversal and deduplication. -/// `effectiveRect` is the geometry after the regular presentation policy. Raw projection sets both -/// to the reported frame, so callers cannot accidentally publish an unclassified geometry source. -struct SnapshotPresentationNode { - let raw: RawAXNode - let effectiveRect: SnapshotRect - - init(raw: RawAXNode, effectiveRect: SnapshotRect) { - self.raw = raw - self.effectiveRect = effectiveRect - } - - static func reported(_ raw: RawAXNode) -> Self { - Self(raw: raw, effectiveRect: raw.rect) - } -} - -/// Geometry conversion owned by presentation. Acquisition reports frames; this type computes the -/// only rectangle that can cross the regular snapshot wire boundary. -enum SnapshotGeometry { - static func effectiveFrame( - reportedFrame: CGRect, - viewport: CGRect, - ancestorClip: CGRect? - ) -> CGRect { - var frame = reportedFrame - if !viewport.isInfinite { - frame = clipped(frame, to: viewport) - } - if let ancestorClip { - frame = clipped(frame, to: ancestorClip) - } - return frame - } - - static func snapshotRect(from frame: CGRect, reportedFrame: CGRect) -> SnapshotRect { - guard !frame.isNull, !frame.isEmpty else { - return SnapshotRect( - x: Double(reportedFrame.minX), y: Double(reportedFrame.minY), width: 0, height: 0) - } - return SnapshotRect( - x: Double(frame.origin.x), - y: Double(frame.origin.y), - width: Double(max(0, frame.size.width)), - height: Double(max(0, frame.size.height)) - ) - } - - private static func clipped(_ frame: CGRect, to clip: CGRect) -> CGRect { - guard !frame.isNull, !frame.isEmpty else { return frame } - let intersection = frame.intersection(clip) - guard !intersection.isNull, !intersection.isEmpty else { - return CGRect(x: frame.minX, y: frame.minY, width: 0, height: 0) - } - return intersection - } -} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentationInvariant.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentationInvariant.swift index 9b37e3eee7..13c7fad9f3 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentationInvariant.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentationInvariant.swift @@ -1,150 +1,4 @@ -import Foundation - -/// A presentation failure is a runner-owned degradation, not evidence that the app published a -/// sparse accessibility tree. The capture plan maps this typed failure onto its existing quality -/// payload at the acquisition/presentation boundary. -enum SnapshotPresentationFailure: Error { - case regularNodeOutsideCumulativeClip(index: Int, frame: SnapshotRect, clip: SnapshotRect) - case regularDegenerateNodeIsActionable(index: Int, frame: SnapshotRect) - - var code: String { "IOS_SNAPSHOT_PRESENTATION_FAILED" } - - var qualityReasonCode: String { "presentation-failed" } - - var message: String { - switch self { - case .regularNodeOutsideCumulativeClip(let index, _, _): - return "regular snapshot node \(index) escaped its cumulative clip" - case .regularDegenerateNodeIsActionable(let index, _): - return "regular snapshot node \(index) with a frameless or degenerate frame was marked hittable" - } - } - - var hint: String { - "This is a runner presentation bug: report it with the failing command and the app under test." - } -} - -/// The choke-point check for the regular presentation. It consumes the folded presentation -/// nodes, not raw acquisition facts, so replacing the fold with reported geometry cannot silently -/// reach `PresentedNode` construction. -extension SnapshotPresentation { - private struct InvariantValidationMetrics { - var parentClipLookups = 0 - } - - static func validateRegularInvariant( - _ nodes: [SnapshotPresentationNode], - viewport: CGRect, - policy: SnapshotVisibilityFold.Policy - ) throws { - var metrics = InvariantValidationMetrics() - try validateRegularInvariant( - nodes, - viewport: viewport, - policy: policy, - metrics: &metrics - ) - } - - private static func validateRegularInvariant( - _ nodes: [SnapshotPresentationNode], - viewport: CGRect, - policy: SnapshotVisibilityFold.Policy, - metrics: inout InvariantValidationMetrics - ) throws { - // SnapshotVisibilityFold emits preorder, so every parent clip is available when its child is - // visited. The effective frame already contains the fold's viewport/ancestor clipping. Cache - // a scroll node's effective frame for descendants; the node itself is checked against its - // parent's context. - var clipIncludingNodeByIndex: [Int: CGRect] = [:] - clipIncludingNodeByIndex.reserveCapacity(nodes.count) - - for node in nodes { - let ancestorClip: CGRect - if let parentIndex = node.raw.parentIndex { - metrics.parentClipLookups += 1 - ancestorClip = clipIncludingNodeByIndex[parentIndex] ?? viewport - } else { - ancestorClip = viewport - } - - let frame = cgRect(from: node.effectiveRect) - let clipIncludingNode: CGRect - if policy == .cursorProjected, - SnapshotVisibilityFold.scrollContainerTypeNames.contains(node.raw.type), - !frame.isNull, - !frame.isEmpty - { - clipIncludingNode = frame - } else { - clipIncludingNode = ancestorClip - } - clipIncludingNodeByIndex[node.raw.index] = clipIncludingNode - - guard !frame.isNull, !frame.isEmpty else { - if node.raw.hittable { - throw SnapshotPresentationFailure.regularDegenerateNodeIsActionable( - index: node.raw.index, - frame: node.effectiveRect - ) - } - continue - } - - guard contains(frame, in: ancestorClip) else { - throw SnapshotPresentationFailure.regularNodeOutsideCumulativeClip( - index: node.raw.index, - frame: node.effectiveRect, - clip: snapshotRect(from: ancestorClip) - ) - } - } - } - - private static func contains(_ frame: CGRect, in clip: CGRect) -> Bool { - guard !clip.isNull, !clip.isEmpty else { return false } - let tolerance = 0.0001 - return frame.minX >= clip.minX - tolerance - && frame.minY >= clip.minY - tolerance - && frame.maxX <= clip.maxX + tolerance - && frame.maxY <= clip.maxY + tolerance - } - - private static func cgRect(from rect: SnapshotRect) -> CGRect { - CGRect(x: rect.x, y: rect.y, width: rect.width, height: rect.height) - } - - private static func snapshotRect(from rect: CGRect) -> SnapshotRect { - SnapshotRect( - x: Double(rect.minX), - y: Double(rect.minY), - width: Double(rect.width), - height: Double(rect.height) - ) - } - -#if AGENT_DEVICE_RUNNER_UNIT_TESTS - struct InvariantValidationStats { - let parentClipLookups: Int - } - - static func validateRegularInvariantForTesting( - _ nodes: [SnapshotPresentationNode], - viewport: CGRect, - policy: SnapshotVisibilityFold.Policy - ) throws -> InvariantValidationStats { - var metrics = InvariantValidationMetrics() - try validateRegularInvariant( - nodes, - viewport: viewport, - policy: policy, - metrics: &metrics - ) - return InvariantValidationStats(parentClipLookups: metrics.parentClipLookups) - } -#endif -} +import AgentDeviceSnapshotPresentation extension RunnerTests { static func snapshotCaptureFailure( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentationModels.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentationModels.swift deleted file mode 100644 index 9b49318b7b..0000000000 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentationModels.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation - -/// Backend-owned snapshot output before it crosses the presentation seam. -/// -/// The incremental #1797 migration still carries derived fields that later semantic layers move -/// behind `SnapshotPresentation`. Eligibility is no longer one of those backend-owned decisions. -struct RawAXNode { - let index: Int - let type: String - let label: String? - let identifier: String? - let value: String? - let rect: SnapshotRect - let enabled: Bool - let focused: Bool? - let selected: Bool? - let hittable: Bool - let depth: Int - let parentIndex: Int? - let hiddenContentAbove: Bool? - let hiddenContentBelow: Bool? - var actions: [String]? = nil - - var hasSemanticContent: Bool { - [label, identifier, value].contains { - !($0?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) - } - } -} - -/// The acquisition-facing view of a snapshot request, derived once by -/// `SnapshotPresentation.captureHint(for:)`. -/// -/// Backends read a hint, never `PresentationOptions`: presentation owns interpretation, and a hint -/// may narrow acquisition only where the backend can prove the narrowing complete for the requested -/// projection (#1797 conservatism). Everything else a hint carries is budget and ordering. -struct CaptureHint { - /// Which projection this acquisition must serve. Backends that cannot serve one are not planned - /// for it, and presentation refuses an acquisition captured for the other projection rather - /// than relabeling it. - enum Projection: String { - case regular - case raw - } - - let projection: Projection - /// Raw traversal-depth budget. This is populated only for raw captures, where - /// presented depth is raw acquisition depth by contract. - let depth: Int? - /// Requested regular depth after presentation collapses structural wrappers. - /// It is populated only for unscoped regular captures; scoped depth is applied - /// after the scope root is selected and therefore cannot narrow acquisition. - let regularPresentedDepth: Int? - /// Regular-projection acquisition budget. Raw projection is the acquired tree and never carries - /// this narrowing. - let interactiveOnly: Bool - let customActions: Bool - - var rawTraversalDepth: Int? { - projection == .raw ? depth : nil - } - - var isRaw: Bool { projection == .raw } -} - -/// One backend attempt after acquisition and its current backend-specific interpretation. -/// -/// It is the only input snapshot presentation accepts, and it carries the hint it was captured -/// under so the two sides of the seam cannot disagree about which projection this is. -struct SnapshotAcquisition { - let hint: CaptureHint - let nodes: [RawAXNode] - let truncated: Bool - let effectiveDepth: Int? - var customActions: SnapshotCustomActionCoverage? = nil - /// Viewport the regular projection's clip fold runs against. `.infinite` disables the fold for - /// raw acquisitions and depth-0 probes. - let viewport: CGRect -} - -/// Keep the wire payload's existing spelling while making the presented-node type owned by the -/// presentation module. Its constructors live in `RunnerTests+SnapshotPresentation.swift` and are -/// file-private there, so acquisition backends can carry a `PresentedNode` but cannot construct one. -typealias PresentedNode = SnapshotPresentation.PresentedNode diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift index 9642afe6d5..2c784467f3 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift @@ -1,4 +1,5 @@ import XCTest +import AgentDeviceSnapshotPresentation extension RunnerTests { // MARK: - Blocking System Modal Snapshot diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SelectorMatchPolicyTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SelectorMatchPolicyTests.swift index aa617855de..84afb05187 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SelectorMatchPolicyTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SelectorMatchPolicyTests.swift @@ -1,4 +1,5 @@ import XCTest +import AgentDeviceSnapshotPresentation extension RunnerTests { #if AGENT_DEVICE_RUNNER_UNIT_TESTS diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotHittabilityTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotHittabilityTests.swift index 925041968a..61e0024121 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotHittabilityTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotHittabilityTests.swift @@ -1,6 +1,7 @@ #if AGENT_DEVICE_RUNNER_UNIT_TESTS import Foundation import XCTest +import AgentDeviceSnapshotPresentation extension RunnerTests { func testRegularPresentationPublishesGeometricActionabilityWithoutOcclusionOrTypeGate() throws { @@ -100,7 +101,7 @@ extension RunnerTests { ), options: options ) - let presented = try XCTUnwrap(capture.payload.nodes) + let presented = capture.nodes XCTAssertEqual(presented.first { $0.label == "Covered button" }?.hittable, false) XCTAssertEqual(presented.first { $0.label == "Labeled image" }?.hittable, false) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationConformanceTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationConformanceTests.swift index 8172bc5c55..718eb5651f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationConformanceTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationConformanceTests.swift @@ -1,4 +1,5 @@ import XCTest +import AgentDeviceSnapshotPresentation #if AGENT_DEVICE_RUNNER_UNIT_TESTS private struct SnapshotPresentationConformanceFixture: Decodable { @@ -107,7 +108,7 @@ extension RunnerTests { let presented: [PresentedNode] if testCase.projection == "raw" { presented = try XCTUnwrap( - SnapshotPresentation.presentRaw(acquisition, options: options).payload.nodes, + SnapshotPresentation.presentRaw(acquisition, options: options).nodes, testCase.name ) } else { @@ -116,7 +117,7 @@ extension RunnerTests { acquisition, options: options, policy: .cursorProjected - ).payload.nodes, + ).nodes, testCase.name ) } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationGeometryTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationGeometryTests.swift index 20d46a2619..087f00e510 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationGeometryTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationGeometryTests.swift @@ -1,5 +1,6 @@ #if AGENT_DEVICE_RUNNER_UNIT_TESTS import XCTest +import AgentDeviceSnapshotPresentation extension RunnerTests { func testEffectiveGeometryIntersectsViewportAndAncestorClip() { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationInvariantTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationInvariantTests.swift index f744bdf54f..cfa4df03e1 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationInvariantTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationInvariantTests.swift @@ -1,5 +1,6 @@ #if AGENT_DEVICE_RUNNER_UNIT_TESTS import XCTest +import AgentDeviceSnapshotPresentation extension RunnerTests { private struct FixedSeedGenerator { @@ -153,7 +154,7 @@ extension RunnerTests { interactiveOnly: false, depth: nil, scope: nil, raw: false) let capture = try SnapshotPresentation.presentRegular( acquisition, options: options, policy: .cursorProjected) - let nodes = try XCTUnwrap(capture.payload.nodes) + let nodes = capture.nodes XCTAssertEqual(nodes.compactMap(\.label), ["App", "Outer", "Inner", "Partially visible"]) let clipped = try XCTUnwrap(nodes.first { $0.label == "Partially visible" }) @@ -210,7 +211,7 @@ extension RunnerTests { let nodes = try XCTUnwrap( try SnapshotPresentation.presentRegular( acquisition, options: options, policy: .cursorProjected - ).payload.nodes) + ).nodes) XCTAssertEqual(nodes.compactMap(\.label), [ "App", "Geometryless semantics", "Child is not clipped", "Zero-area semantics", @@ -256,7 +257,7 @@ extension RunnerTests { viewport: .infinite ), options: options - ).payload.nodes) + ).nodes) XCTAssertEqual(raw.map(\.label), ["App", "Offscreen", "Frameless"]) XCTAssertEqual(raw[1].rect.x, 200) @@ -306,7 +307,7 @@ extension RunnerTests { } XCTAssertThrowsError( - try SnapshotPresentation.validateRegularInvariantForTesting( + try SnapshotPresentationInvariant.validateRegularWithStats( folded, viewport: viewport, policy: .cursorProjected @@ -335,7 +336,7 @@ extension RunnerTests { return SnapshotPresentationNode(raw: raw, effectiveRect: raw.rect) } - let stats = try SnapshotPresentation.validateRegularInvariantForTesting( + let stats = try SnapshotPresentationInvariant.validateRegularWithStats( nodes, viewport: viewport, policy: .cursorProjected diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift index 79874e634e..059fcecd2d 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationTests.swift @@ -1,5 +1,6 @@ #if AGENT_DEVICE_RUNNER_UNIT_TESTS import XCTest +import AgentDeviceSnapshotPresentation extension RunnerTests { func testSnapshotPresentationPreservesCurrentWireShape() throws { @@ -48,14 +49,14 @@ extension RunnerTests { raw: true ) )) - let nodes = try XCTUnwrap(capture.payload.nodes) + let nodes = capture.nodes let encoded = try encoder.encode(nodes) XCTAssertEqual( String(decoding: encoded, as: UTF8.self), #"[{"actions":["Open menu"],"depth":2,"enabled":true,"focused":true,"hiddenContentAbove":true,"hiddenContentBelow":true,"hittable":true,"identifier":"continue-button","index":3,"label":"Continue","parentIndex":1,"rect":{"height":44,"width":100,"x":10,"y":20},"selected":true,"type":"Button","value":"Ready"}]"# ) - XCTAssertEqual(capture.payload.truncated, true) + XCTAssertEqual(capture.truncated, true) XCTAssertEqual(capture.effectiveDepth, 4) XCTAssertEqual(capture.customActions?.read, 1) XCTAssertEqual(capture.customActions?.candidates, 2) @@ -114,7 +115,7 @@ extension RunnerTests { nodes: acquired, truncated: false, effectiveDepth: nil, viewport: CGRect(x: 0, y: 0, width: 1_000, height: 1_000)), options: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false) - ).payload.nodes + ).nodes ) let interactive = try XCTUnwrap( try SnapshotPresentation.presentRegular( @@ -125,7 +126,7 @@ extension RunnerTests { nodes: acquired, truncated: false, effectiveDepth: nil, viewport: CGRect(x: 0, y: 0, width: 1_000, height: 1_000)), options: PresentationOptions(interactiveOnly: true, depth: nil, scope: nil, raw: false) - ).payload.nodes + ).nodes ) XCTAssertEqual( @@ -148,7 +149,7 @@ extension RunnerTests { interactiveOnly: false, customActions: false), nodes: acquired, truncated: false, effectiveDepth: nil, viewport: .infinite), options: PresentationOptions(interactiveOnly: true, depth: nil, scope: nil, raw: true) - ).payload.nodes + ).nodes ) XCTAssertEqual(raw.map(\.index), Array(0...10)) XCTAssertEqual(raw.last?.depth, 2) @@ -182,9 +183,9 @@ extension RunnerTests { acquisition, options: PresentationOptions( interactiveOnly: false, depth: nil, scope: nil, raw: false) - ).payload.nodes + ).nodes - XCTAssertEqual(presented?.compactMap(\.label), ["App"]) + XCTAssertEqual(presented.compactMap(\.label), ["App"]) } func testRegularPresentationPublishesEffectiveRectWhileRawKeepsReportedFrame() throws { @@ -213,7 +214,7 @@ extension RunnerTests { viewport: viewport ), options: regularOptions - ).payload.nodes + ).nodes ) let regularButton = try XCTUnwrap(regular.first { $0.label == "Partially clipped" }) XCTAssertEqual(regularButton.rect.x, 350) @@ -233,7 +234,7 @@ extension RunnerTests { viewport: .infinite ), options: rawOptions - ).payload.nodes + ).nodes ) let rawButton = try XCTUnwrap(raw.first { $0.label == "Partially clipped" }) XCTAssertEqual(rawButton.rect.x, 350) @@ -294,14 +295,14 @@ extension RunnerTests { raw: false ) let capture = try XCTUnwrap(try SnapshotPresentation.present(acquisition, options: options)) - let nodes = try XCTUnwrap(capture.payload.nodes) + let nodes = capture.nodes XCTAssertEqual(nodes.map(\.label), [nil, "Child"]) XCTAssertEqual(nodes.map(\.identifier), ["scope-root", nil]) XCTAssertEqual(nodes.map(\.index), [0, 1]) XCTAssertEqual(nodes.map(\.depth), [0, 1]) XCTAssertEqual(nodes.map(\.parentIndex), [nil, 0]) - XCTAssertEqual(capture.qualityPayload?.nodes?.count, 6) + XCTAssertEqual(capture.qualityNodes?.count, 6) let raw = try XCTUnwrap( SnapshotPresentation.presentRaw( @@ -320,7 +321,7 @@ extension RunnerTests { scope: "scope-root", raw: true ) - ).payload.nodes + ).nodes ) XCTAssertEqual(raw.map(\.type), ["Other", "StaticText", "Image"]) XCTAssertEqual(raw.map(\.depth), [0, 1, 1]) @@ -339,8 +340,12 @@ extension RunnerTests { raw: false ) )) - XCTAssertEqual(missing.payload.nodes?.count, 0) - XCTAssertNil(RunnerTests.sparsePayloadReason(try XCTUnwrap(missing.qualityPayload))) + XCTAssertEqual(missing.nodes.count, 0) + XCTAssertNil( + RunnerTests.sparsePayloadReason( + DataPayload(nodes: missing.qualityNodes, truncated: missing.truncated) + ) + ) } /// #1797 D4: a backend that answers a `--raw` request with a regular capture (or the reverse) @@ -383,8 +388,8 @@ extension RunnerTests { regularAcquisition, options: regularRequest) XCTAssertNil(regularCaptureForRawRequest) XCTAssertNil(rawCaptureForRegularRequest) - XCTAssertEqual(rawCapture?.payload.nodes?.count, 2) - XCTAssertEqual(regularCapture?.payload.nodes?.count, 2) + XCTAssertEqual(rawCapture?.nodes.count, 2) + XCTAssertEqual(regularCapture?.nodes.count, 2) } /// The one derivation every backend reads. Non-vacuity: returning the request's own depth for a @@ -486,8 +491,8 @@ extension RunnerTests { XCTAssertFalse( SnapshotPresentation.shouldAcquireChildren( for: hint, rawDepth: 2, regularPresentedDepth: 1)) - XCTAssertEqual(capture.payload.nodes?.map(\.label), ["App", "Save"]) - XCTAssertEqual(capture.payload.nodes?.map(\.depth), [0, 1]) + XCTAssertEqual(capture.nodes.map(\.label), ["App", "Save"]) + XCTAssertEqual(capture.nodes.map(\.depth), [0, 1]) } /// #1797 P1: an eligible parent outside the viewport is removed by the shared visibility fold, @@ -558,7 +563,7 @@ extension RunnerTests { viewport: viewport ) let presented = try XCTUnwrap( - SnapshotPresentation.present(acquisition, options: options)?.payload.nodes) + SnapshotPresentation.present(acquisition, options: options)?.nodes) XCTAssertEqual(presented.map(\.label), ["App", "Projected child"]) XCTAssertEqual(presented.map(\.depth), [0, 1]) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift index f1c5e8c172..2063c56f14 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotVisibilityFoldTests.swift @@ -1,5 +1,6 @@ #if AGENT_DEVICE_RUNNER_UNIT_TESTS import XCTest +import AgentDeviceSnapshotPresentation extension RunnerTests { private static func foldNode( diff --git a/apple/snapshot-presentation/Package.runner.swift b/apple/snapshot-presentation/Package.runner.swift new file mode 100644 index 0000000000..0b0a701308 --- /dev/null +++ b/apple/snapshot-presentation/Package.runner.swift @@ -0,0 +1,19 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "agent-device-snapshot-presentation", + platforms: [ + .iOS(.v15), + .macOS(.v13), + ], + products: [ + .library( + name: "AgentDeviceSnapshotPresentation", + targets: ["AgentDeviceSnapshotPresentation"] + ), + ], + targets: [ + .target(name: "AgentDeviceSnapshotPresentation"), + ] +) diff --git a/apple/snapshot-presentation/Package.swift b/apple/snapshot-presentation/Package.swift new file mode 100644 index 0000000000..b76923131e --- /dev/null +++ b/apple/snapshot-presentation/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "agent-device-snapshot-presentation", + platforms: [ + .iOS(.v15), + .macOS(.v13), + ], + products: [ + .library( + name: "AgentDeviceSnapshotPresentation", + targets: ["AgentDeviceSnapshotPresentation"] + ), + .executable( + name: "snapshot-presentation-conformance", + targets: ["SnapshotPresentationConformance"] + ), + ], + targets: [ + .target(name: "AgentDeviceSnapshotPresentation"), + .executableTarget( + name: "SnapshotPresentationConformance", + dependencies: ["AgentDeviceSnapshotPresentation"] + ), + .testTarget( + name: "AgentDeviceSnapshotPresentationTests", + dependencies: ["AgentDeviceSnapshotPresentation"] + ), + ] +) diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift new file mode 100644 index 0000000000..ec8bc50863 --- /dev/null +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotGeometry.swift @@ -0,0 +1,56 @@ +import Foundation +import CoreGraphics + +public enum SnapshotGeometry { + public static func effectiveFrame( + reportedFrame: CGRect, + viewport: CGRect, + ancestorClip: CGRect? + ) -> CGRect { + var frame = reportedFrame + if !viewport.isInfinite { + frame = clipped(frame, to: viewport) + } + if let ancestorClip { + frame = clipped(frame, to: ancestorClip) + } + return frame + } + + public static func snapshotRect(from frame: CGRect, reportedFrame: CGRect) -> SnapshotRect { + guard !frame.isNull, !frame.isEmpty else { + return SnapshotRect( + x: Double(reportedFrame.minX), + y: Double(reportedFrame.minY), + width: 0, + height: 0 + ) + } + return SnapshotRect( + x: Double(frame.origin.x), + y: Double(frame.origin.y), + width: Double(max(0, frame.size.width)), + height: Double(max(0, frame.size.height)) + ) + } + + public static func isGeometricallyActionable( + enabled: Bool, + frame: CGRect, + viewport: CGRect + ) -> Bool { + guard enabled, !frame.isNull, !frame.isEmpty else { return false } + if viewport.isInfinite { return true } + let center = CGPoint(x: frame.midX, y: frame.midY) + return viewport.contains(center) + } + + private static func clipped(_ frame: CGRect, to clip: CGRect) -> CGRect { + guard !frame.isNull, !frame.isEmpty else { return frame } + let intersection = frame.intersection(clip) + guard !intersection.isNull, !intersection.isEmpty else { + return CGRect(x: frame.minX, y: frame.minY, width: 0, height: 0) + } + return intersection + } +} diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift new file mode 100644 index 0000000000..fd8b0a048e --- /dev/null +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotModels.swift @@ -0,0 +1,259 @@ +import Foundation +import CoreGraphics + +public struct SnapshotRect: Codable, Equatable { + public let x: Double + public let y: Double + public let width: Double + public let height: Double + + public init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } + + public var cgRect: CGRect { + CGRect(x: x, y: y, width: width, height: height) + } +} + +public struct RawAXNode: Equatable { + public let index: Int + public let type: String + public let label: String? + public let identifier: String? + public let value: String? + public let rect: SnapshotRect + public let enabled: Bool + public let focused: Bool? + public let selected: Bool? + public let hittable: Bool + public let depth: Int + public let parentIndex: Int? + public let hiddenContentAbove: Bool? + public let hiddenContentBelow: Bool? + public var actions: [String]? + + public init( + index: Int, + type: String, + label: String?, + identifier: String?, + value: String?, + rect: SnapshotRect, + enabled: Bool, + focused: Bool?, + selected: Bool?, + hittable: Bool, + depth: Int, + parentIndex: Int?, + hiddenContentAbove: Bool?, + hiddenContentBelow: Bool?, + actions: [String]? = nil + ) { + self.index = index + self.type = type + self.label = label + self.identifier = identifier + self.value = value + self.rect = rect + self.enabled = enabled + self.focused = focused + self.selected = selected + self.hittable = hittable + self.depth = depth + self.parentIndex = parentIndex + self.hiddenContentAbove = hiddenContentAbove + self.hiddenContentBelow = hiddenContentBelow + self.actions = actions + } + + public var hasSemanticContent: Bool { + [label, identifier, value].contains { + !($0?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + } + } +} + +public struct CaptureHint: Equatable { + public enum Projection: String { + case regular + case raw + } + + public let projection: Projection + public let depth: Int? + public let regularPresentedDepth: Int? + public let interactiveOnly: Bool + public let customActions: Bool + + public init( + projection: Projection, + depth: Int?, + regularPresentedDepth: Int?, + interactiveOnly: Bool, + customActions: Bool + ) { + self.projection = projection + self.depth = depth + self.regularPresentedDepth = regularPresentedDepth + self.interactiveOnly = interactiveOnly + self.customActions = customActions + } + + public var rawTraversalDepth: Int? { + projection == .raw ? depth : nil + } + + public var isRaw: Bool { + projection == .raw + } +} + +public struct PresentationOptions: Equatable { + public let interactiveOnly: Bool + public let depth: Int? + public let scope: String? + public let raw: Bool + public var preferredBackend: String? + public var customActions: Bool + + public init( + interactiveOnly: Bool, + depth: Int?, + scope: String?, + raw: Bool, + preferredBackend: String? = nil, + customActions: Bool = false + ) { + self.interactiveOnly = interactiveOnly + self.depth = depth + self.scope = scope + self.raw = raw + self.preferredBackend = preferredBackend + self.customActions = customActions + } +} + +public struct SnapshotAcquisition { + public let hint: CaptureHint + public let nodes: [RawAXNode] + public let truncated: Bool + public let effectiveDepth: Int? + public var customActions: SnapshotCustomActionCoverage? + public let viewport: CGRect + + public init( + hint: CaptureHint, + nodes: [RawAXNode], + truncated: Bool, + effectiveDepth: Int?, + customActions: SnapshotCustomActionCoverage? = nil, + viewport: CGRect + ) { + self.hint = hint + self.nodes = nodes + self.truncated = truncated + self.effectiveDepth = effectiveDepth + self.customActions = customActions + self.viewport = viewport + } +} + +public struct SnapshotCustomActionCoverage: Codable, Equatable { + public let read: Int + public let candidates: Int + public let truncated: Int + public let blocked: Bool + + public init(read: Int, candidates: Int, truncated: Int, blocked: Bool) { + self.read = read + self.candidates = candidates + self.truncated = truncated + self.blocked = blocked + } +} + +public struct SnapshotPresentationNode { + public let raw: RawAXNode + public let effectiveRect: SnapshotRect + + public init(raw: RawAXNode, effectiveRect: SnapshotRect) { + self.raw = raw + self.effectiveRect = effectiveRect + } + + public static func reported(_ raw: RawAXNode) -> Self { + Self(raw: raw, effectiveRect: raw.rect) + } +} + +public struct PresentedNode: Codable, Equatable { + public let index: Int + public let type: String + public let label: String? + public let identifier: String? + public let value: String? + public let rect: SnapshotRect + public let enabled: Bool + public let focused: Bool? + public let selected: Bool? + public let hittable: Bool + public let depth: Int + public let parentIndex: Int? + public let hiddenContentAbove: Bool? + public let hiddenContentBelow: Bool? + public let actions: [String]? + + internal init( + presenting raw: RawAXNode, + rect: SnapshotRect? = nil, + index: Int? = nil, + depth: Int? = nil, + parentIndex: Int?? = nil + ) { + self.index = index ?? raw.index + self.type = raw.type + self.label = raw.label + self.identifier = raw.identifier + self.value = raw.value + self.rect = rect ?? raw.rect + self.enabled = raw.enabled + self.focused = raw.focused + self.selected = raw.selected + self.hittable = raw.hittable + self.depth = depth ?? raw.depth + self.parentIndex = parentIndex ?? raw.parentIndex + self.hiddenContentAbove = raw.hiddenContentAbove + self.hiddenContentBelow = raw.hiddenContentBelow + self.actions = raw.actions + } + + internal init(presenting node: SnapshotPresentationNode) { + self.init(presenting: node.raw, rect: node.effectiveRect) + } +} + +public struct SnapshotPresentationResult { + public let nodes: [PresentedNode] + public let truncated: Bool + public let effectiveDepth: Int? + public let customActions: SnapshotCustomActionCoverage? + public let qualityNodes: [PresentedNode]? + + public init( + nodes: [PresentedNode], + truncated: Bool, + effectiveDepth: Int?, + customActions: SnapshotCustomActionCoverage?, + qualityNodes: [PresentedNode]? + ) { + self.nodes = nodes + self.truncated = truncated + self.effectiveDepth = effectiveDepth + self.customActions = customActions + self.qualityNodes = qualityNodes + } +} diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentation.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentation.swift new file mode 100644 index 0000000000..ef010f49c8 --- /dev/null +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentation.swift @@ -0,0 +1,155 @@ +import Foundation +import CoreGraphics + +public enum SnapshotPresentation { + public typealias PresentedNode = AgentDeviceSnapshotPresentation.PresentedNode + + public static func present( + _ acquisition: SnapshotAcquisition, + options: PresentationOptions + ) throws -> SnapshotPresentationResult? { + let requested = captureHint(for: options) + guard acquisition.hint.projection == requested.projection else { + return nil + } + switch requested.projection { + case .regular: + return try presentRegular(acquisition, options: options) + case .raw: + return presentRaw(acquisition, options: options) + } + } + + public static func presentRegular( + _ acquisition: SnapshotAcquisition, + options: PresentationOptions, + policy: SnapshotVisibilityFold.Policy = .platformDefault + ) throws -> SnapshotPresentationResult { + let folded = SnapshotVisibilityFold.fold( + acquisition.nodes, + viewport: acquisition.viewport, + interactiveOnly: options.interactiveOnly, + policy: policy + ) + try SnapshotPresentationInvariant.validateRegular( + folded, + viewport: acquisition.viewport, + policy: policy + ) + return project( + folded, + acquisition: acquisition, + options: options, + projection: .regular + ) + } + + public static func presentRaw( + _ acquisition: SnapshotAcquisition, + options: PresentationOptions + ) -> SnapshotPresentationResult { + project( + acquisition.nodes.map(SnapshotPresentationNode.reported), + acquisition: acquisition, + options: options, + projection: .raw + ) + } + + public static func captureHint(for options: PresentationOptions) -> CaptureHint { + let scoped = SnapshotScopePolicy.isActive(options.scope) + let projection: CaptureHint.Projection = options.raw ? .raw : .regular + return CaptureHint( + projection: projection, + depth: scoped || projection == .regular ? nil : options.depth, + regularPresentedDepth: scoped || projection == .raw ? nil : options.depth, + interactiveOnly: projection == .raw ? false : options.interactiveOnly, + customActions: options.customActions + ) + } + + public static func shouldAcquireChildren( + for hint: CaptureHint, + rawDepth: Int, + regularPresentedDepth: Int + ) -> Bool { + if let rawLimit = hint.rawTraversalDepth { + return rawDepth < rawLimit + } + if let presentedLimit = hint.regularPresentedDepth { + return regularPresentedDepth < presentedLimit + } + return true + } + + public static func regularTraversalTransition( + for raw: RawAXNode, + parentPresentedDepth: Int, + parentTraversal: SnapshotVisibilityFold.TraversalState, + hint: CaptureHint, + rawDepth: Int, + viewport: CGRect, + hasChildren: Bool, + isDuplicate: Bool, + policy: SnapshotVisibilityFold.Policy = .platformDefault + ) -> ( + presentedDepth: Int, + traversal: SnapshotVisibilityFold.TraversalState, + shouldVisitChildren: Bool + ) { + guard hint.regularPresentedDepth != nil else { + return ( + parentPresentedDepth, + parentTraversal, + shouldAcquireChildren( + for: hint, + rawDepth: rawDepth, + regularPresentedDepth: parentPresentedDepth + ) + ) + } + + let visibility = SnapshotVisibilityFold.traversalDecision( + for: raw, + parent: parentTraversal, + viewport: viewport, + interactiveOnly: hint.interactiveOnly, + hasChildren: hasChildren, + policy: policy + ) + let presentedDepth = regularPresentedDepth( + for: raw, + parentPresentedDepth: parentPresentedDepth, + visibility: visibility + ) + let nextPresentedDepth = isDuplicate ? parentPresentedDepth : presentedDepth + let nextTraversal = isDuplicate ? parentTraversal : visibility.descendants + let descendantsMayBeVisible = isDuplicate + ? parentTraversal.descendantsMayBeVisible + : visibility.descendantsMayBeVisible + return ( + nextPresentedDepth, + nextTraversal, + shouldAcquireChildren( + for: hint, + rawDepth: rawDepth, + regularPresentedDepth: nextPresentedDepth + ) && descendantsMayBeVisible + ) + } + + public static func regularPresentedDepth( + for raw: RawAXNode, + parentPresentedDepth: Int, + visibility: SnapshotVisibilityFold.TraversalDecision + ) -> Int { + guard raw.parentIndex != nil else { return 0 } + return visibility.isIncluded && isEligibleForRegularPresentation(raw) + ? parentPresentedDepth + 1 + : parentPresentedDepth + } + + public static func singleElementRead(_ node: RawAXNode) -> PresentedNode { + PresentedNode(presenting: node) + } +} diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationFailure.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationFailure.swift new file mode 100644 index 0000000000..b58accae59 --- /dev/null +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationFailure.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum SnapshotPresentationFailure: Error { + case regularNodeOutsideCumulativeClip(index: Int, frame: SnapshotRect, clip: SnapshotRect) + case regularDegenerateNodeIsActionable(index: Int, frame: SnapshotRect) + + public var code: String { + "IOS_SNAPSHOT_PRESENTATION_FAILED" + } + + public var qualityReasonCode: String { + "presentation-failed" + } + + public var message: String { + switch self { + case .regularNodeOutsideCumulativeClip(let index, _, _): + return "regular snapshot node \(index) escaped its cumulative clip" + case .regularDegenerateNodeIsActionable(let index, _): + return "regular snapshot node \(index) with a frameless or degenerate frame was marked hittable" + } + } + + public var hint: String { + "This is a runner presentation bug: report it with the failing command and the app under test." + } +} diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationInvariant.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationInvariant.swift new file mode 100644 index 0000000000..0687d9a9f4 --- /dev/null +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationInvariant.swift @@ -0,0 +1,91 @@ +import Foundation +import CoreGraphics + +public enum SnapshotPresentationInvariant { + public struct ValidationStats: Equatable { + public let parentClipLookups: Int + + public init(parentClipLookups: Int) { + self.parentClipLookups = parentClipLookups + } + } + + public static func validateRegular( + _ nodes: [SnapshotPresentationNode], + viewport: CGRect, + policy: SnapshotVisibilityFold.Policy + ) throws { + _ = try validateRegularWithStats(nodes, viewport: viewport, policy: policy) + } + + public static func validateRegularWithStats( + _ nodes: [SnapshotPresentationNode], + viewport: CGRect, + policy: SnapshotVisibilityFold.Policy + ) throws -> ValidationStats { + var parentClipLookups = 0 + var clipIncludingNodeByIndex: [Int: CGRect] = [:] + clipIncludingNodeByIndex.reserveCapacity(nodes.count) + + for node in nodes { + let ancestorClip: CGRect + if let parentIndex = node.raw.parentIndex { + parentClipLookups += 1 + ancestorClip = clipIncludingNodeByIndex[parentIndex] ?? viewport + } else { + ancestorClip = viewport + } + + let frame = node.effectiveRect.cgRect + let clipIncludingNode: CGRect + if policy == .cursorProjected, + SnapshotVisibilityFold.scrollContainerTypeNames.contains(node.raw.type), + !frame.isNull, + !frame.isEmpty + { + clipIncludingNode = frame + } else { + clipIncludingNode = ancestorClip + } + clipIncludingNodeByIndex[node.raw.index] = clipIncludingNode + + guard !frame.isNull, !frame.isEmpty else { + if node.raw.hittable { + throw SnapshotPresentationFailure.regularDegenerateNodeIsActionable( + index: node.raw.index, + frame: node.effectiveRect + ) + } + continue + } + + guard contains(frame, in: ancestorClip) else { + throw SnapshotPresentationFailure.regularNodeOutsideCumulativeClip( + index: node.raw.index, + frame: node.effectiveRect, + clip: snapshotRect(from: ancestorClip) + ) + } + } + + return ValidationStats(parentClipLookups: parentClipLookups) + } + + private static func contains(_ frame: CGRect, in clip: CGRect) -> Bool { + guard !clip.isNull, !clip.isEmpty else { return false } + let tolerance = 0.0001 + return frame.minX >= clip.minX - tolerance + && frame.minY >= clip.minY - tolerance + && frame.maxX <= clip.maxX + tolerance + && frame.maxY <= clip.maxY + tolerance + } + + private static func snapshotRect(from rect: CGRect) -> SnapshotRect { + SnapshotRect( + x: Double(rect.minX), + y: Double(rect.minY), + width: Double(rect.width), + height: Double(rect.height) + ) + } +} diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationProjection.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationProjection.swift new file mode 100644 index 0000000000..3d1ccd8b53 --- /dev/null +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationProjection.swift @@ -0,0 +1,161 @@ +import Foundation + +extension SnapshotPresentation { + static let eligibleInteractiveTypes: Set = [ + "Button", + "Cell", + "CheckBox", + "CollectionView", + "Link", + "MenuItem", + "Picker", + "SearchField", + "SecureTextField", + "SegmentedControl", + "Slider", + "ScrollView", + "Stepper", + "Switch", + "TabBar", + "Table", + "TextField", + "TextView", + "WebView", + ] + + static func project( + _ projectionNodes: [SnapshotPresentationNode], + acquisition: SnapshotAcquisition, + options: PresentationOptions, + projection: CaptureHint.Projection + ) -> SnapshotPresentationResult { + let scopedRawNodes = applyScope(to: projectionNodes, options: options, projection: projection) + let nodes = presentedNodes( + from: scopedRawNodes, + projection: projection, + maximumDepth: projection == .regular ? options.depth : nil + ) + let qualityNodes: [PresentedNode]? = SnapshotScopePolicy.isActive(options.scope) + ? presentedNodes(from: projectionNodes, projection: projection, maximumDepth: nil) + : nil + return SnapshotPresentationResult( + nodes: nodes, + truncated: acquisition.truncated, + effectiveDepth: acquisition.effectiveDepth, + customActions: acquisition.customActions, + qualityNodes: qualityNodes + ) + } + + static func presentedNodes( + from rawNodes: [SnapshotPresentationNode], + projection: CaptureHint.Projection, + maximumDepth: Int? = nil + ) -> [PresentedNode] { + if projection == .raw { + return rawNodes.map { PresentedNode(presenting: $0) } + } + + var nodes: [PresentedNode] = [] + var nearestPresentedNodeByRawIndex: [Int: (index: Int, depth: Int)] = [:] + for node in rawNodes { + let raw = node.raw + let presentedParent = raw.parentIndex.flatMap { nearestPresentedNodeByRawIndex[$0] } + guard isEligibleForRegularPresentation(raw) else { + if let presentedParent { + nearestPresentedNodeByRawIndex[raw.index] = presentedParent + } + continue + } + + let presentedIndex = nodes.count + let presentedDepth = presentedParent.map { $0.depth + 1 } ?? 0 + if let maximumDepth, presentedDepth > maximumDepth { + if let presentedParent { + nearestPresentedNodeByRawIndex[raw.index] = presentedParent + } + continue + } + nearestPresentedNodeByRawIndex[raw.index] = (presentedIndex, presentedDepth) + nodes.append( + PresentedNode( + presenting: raw, + rect: node.effectiveRect, + index: presentedIndex, + depth: presentedDepth, + parentIndex: presentedParent?.index + ) + ) + } + return nodes + } + + static func applyScope( + to rawNodes: [SnapshotPresentationNode], + options: PresentationOptions, + projection: CaptureHint.Projection + ) -> [SnapshotPresentationNode] { + switch SnapshotScopePolicy.select( + fromPreorder: rawNodes, + scope: options.scope, + depth: { $0.raw.depth }, + semanticValues: { [$0.raw.label, $0.raw.identifier, $0.raw.value] }, + subtreeContributes: { range in + projection == .raw || rawNodes[range].contains { isEligibleForRegularPresentation($0.raw) } + } + ) { + case .unscoped: + return rawNodes + case .missing: + return [] + case .matched(let startIndex): + let startDepth = rawNodes[startIndex].raw.depth + let range = SnapshotScopePolicy.subtreeRange( + from: startIndex, + in: rawNodes, + depth: { $0.raw.depth } + ) + let maxDepth = options.depth ?? Int.max + let scopedNodes = projection == .raw + ? Array(rawNodes[range]).filter { $0.raw.depth - startDepth <= maxDepth } + : Array(rawNodes[range]) + return reindex(scopedNodes, depthOffset: startDepth) + } + } + + static func reindex( + _ nodes: [SnapshotPresentationNode], + depthOffset: Int + ) -> [SnapshotPresentationNode] { + let indexMap = Dictionary( + uniqueKeysWithValues: nodes.enumerated().map { ($0.element.raw.index, $0.offset) }) + return nodes.enumerated().map { offset, node in + let raw = node.raw + return SnapshotPresentationNode( + raw: RawAXNode( + index: offset, + type: raw.type, + label: raw.label, + identifier: raw.identifier, + value: raw.value, + rect: raw.rect, + enabled: raw.enabled, + focused: raw.focused, + selected: raw.selected, + hittable: raw.hittable, + depth: max(0, raw.depth - depthOffset), + parentIndex: raw.parentIndex.flatMap { indexMap[$0] }, + hiddenContentAbove: raw.hiddenContentAbove, + hiddenContentBelow: raw.hiddenContentBelow, + actions: raw.actions + ), + effectiveRect: node.effectiveRect + ) + } + } + + static func isEligibleForRegularPresentation(_ node: RawAXNode) -> Bool { + if node.parentIndex == nil { return true } + return eligibleInteractiveTypes.contains(node.type) || node.hasSemanticContent + } +} diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotScopePolicy.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotScopePolicy.swift new file mode 100644 index 0000000000..16ddb3917c --- /dev/null +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotScopePolicy.swift @@ -0,0 +1,53 @@ +import Foundation + +public enum SnapshotScopeSelection: Equatable { + case unscoped + case matched(Int) + case missing +} + +public enum SnapshotScopePolicy { + public static func select( + fromPreorder nodes: [Node], + scope: String?, + depth: (Node) -> Int, + semanticValues: (Node) -> [String?], + subtreeContributes: (Range) -> Bool + ) -> SnapshotScopeSelection { + guard let query = normalized(scope) else { return .unscoped } + for (index, node) in nodes.enumerated() { + guard semanticValues(node).contains(where: { value in + value?.lowercased().contains(query) == true + }) else { continue } + let range = subtreeRange(from: index, in: nodes, depth: depth) + if subtreeContributes(range) { + return .matched(index) + } + } + return .missing + } + + public static func isActive(_ scope: String?) -> Bool { + normalized(scope) != nil + } + + public static func subtreeRange( + from start: Int, + in nodes: [Node], + depth: (Node) -> Int + ) -> Range { + let rootDepth = depth(nodes[start]) + var end = start + 1 + while end < nodes.count, depth(nodes[end]) > rootDepth { + end += 1 + } + return start.. String? { + guard let query = scope?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty else { + return nil + } + return query.lowercased() + } +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotVisibilityFold.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift similarity index 55% rename from apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotVisibilityFold.swift rename to apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift index 94b72b24b7..b9603b2bab 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotVisibilityFold.swift +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift @@ -1,16 +1,12 @@ import Foundation +import CoreGraphics -/// The regular projection's single visibility interpreter for every snapshot backend. It applies -/// the platform clip policy, reparents survivors, and records hidden-content hints behind one pure -/// interface so acquisition backends cannot recreate only part of the projection contract. -enum SnapshotVisibilityFold { - enum Policy { - /// An out-of-clip Cell or scroll container owns its descendants' visibility. +public enum SnapshotVisibilityFold { + public enum Policy { case cursorProjected - /// Each node is intersected independently with the viewport. case plainViewport - static var platformDefault: Policy { + public static var platformDefault: Self { #if os(iOS) return .cursorProjected #else @@ -19,27 +15,26 @@ enum SnapshotVisibilityFold { } } - /// Wire-name vocabulary corresponding to XCTest's scroll-container element types. - static let scrollContainerTypeNames: Set = ["CollectionView", "ScrollView", "Table"] + public static let scrollContainerTypeNames: Set = ["CollectionView", "ScrollView", "Table"] - fileprivate enum Geometry { + enum Geometry { case geometryless case framed(intersectsClip: Bool) } - fileprivate enum DescendantVisibility { + enum DescendantVisibility { case independent case owned } - fileprivate struct ProjectionCursor { + struct ProjectionCursor { static let root = ProjectionCursor(ancestorProjectedOut: false) private let ancestorProjectedOut: Bool var isProjectedOut: Bool { ancestorProjectedOut } - fileprivate func project( + func project( geometry: Geometry, descendantVisibility: DescendantVisibility ) -> ProjectionDecision { @@ -60,19 +55,17 @@ enum SnapshotVisibilityFold { } } - fileprivate struct ProjectionDecision { + struct ProjectionDecision { let presentationVisible: Bool let descendants: ProjectionCursor } - fileprivate struct ProjectionTransition { + + struct ProjectionTransition { let decision: ProjectionDecision let hiddenContentFrame: CGRect? } - /// The fold's traversal state is also consumed by acquisition when a regular depth frontier is - /// requested. It carries only projection facts; presentation remains the owner of membership - /// and output construction. - struct TraversalState { + public struct TraversalState { fileprivate let cursor: ProjectionCursor fileprivate let ancestorClip: CGRect? @@ -81,17 +74,17 @@ enum SnapshotVisibilityFold { self.ancestorClip = ancestorClip } - static let root = TraversalState(cursor: .root, ancestorClip: nil) + public static let root = TraversalState(cursor: .root, ancestorClip: nil) - var descendantsMayBeVisible: Bool { !cursor.isProjectedOut } + public var descendantsMayBeVisible: Bool { + !cursor.isProjectedOut + } } - struct TraversalDecision { - /// Whether the shared fold would retain this raw node before regular semantic eligibility. - let isIncluded: Bool - /// Whether any descendant can remain visible under the same projection cursor. - let descendantsMayBeVisible: Bool - let descendants: TraversalState + public struct TraversalDecision { + public let isIncluded: Bool + public let descendantsMayBeVisible: Bool + public let descendants: TraversalState fileprivate let effectiveFrame: CGRect fileprivate let hiddenContentFrame: CGRect? fileprivate let establishesScrollAnchor: Bool @@ -104,10 +97,7 @@ enum SnapshotVisibilityFold { let keptDepth: Int } - private static let negligibleDecorationTolerance = 1.0 - private static let visibilityExemptCarrierTypes: Set = ["Application", "Window"] - - static func traversalDecision( + public static func traversalDecision( for node: RawAXNode, parent: TraversalState, viewport: CGRect, @@ -116,9 +106,7 @@ enum SnapshotVisibilityFold { policy: Policy ) -> TraversalDecision { let ancestorClip = policy == .cursorProjected ? parent.ancestorClip : nil - let rect = CGRect( - x: node.rect.x, y: node.rect.y, width: node.rect.width, height: node.rect.height - ) + let rect = node.rect.cgRect let effectiveFrame = SnapshotGeometry.effectiveFrame( reportedFrame: rect, viewport: viewport, @@ -136,9 +124,7 @@ enum SnapshotVisibilityFold { let negligibleDecoration = policy == .cursorProjected && node.parentIndex != nil && !node.hasSemanticContent - && (rect.isEmpty - || rect.width <= negligibleDecorationTolerance - || rect.height <= negligibleDecorationTolerance) + && (rect.isEmpty || rect.width <= negligibleDecorationTolerance || rect.height <= negligibleDecorationTolerance) let visible = transition.decision.presentationVisible && !negligibleDecoration let isIncluded = shouldInclude( node, @@ -165,7 +151,7 @@ enum SnapshotVisibilityFold { ) } - static func fold( + public static func fold( _ nodes: [RawAXNode], viewport: CGRect, interactiveOnly: Bool, @@ -183,12 +169,12 @@ enum SnapshotVisibilityFold { var hints: [Int: (above: Bool, below: Bool)] = [:] for (offset, node) in nodes.enumerated() { - let parentState = node.parentIndex.flatMap { states[$0] } + let parentState = node.parentIndex.flatMap { index in + index >= 0 && index < states.count ? states[index] : nil + } let parentTraversal = parentState?.traversal ?? .root let parentAnchor = policy == .cursorProjected ? parentState?.anchor : nil - let rect = CGRect( - x: node.rect.x, y: node.rect.y, width: node.rect.width, height: node.rect.height - ) + let rect = node.rect.cgRect let decision = traversalDecision( for: node, parent: parentTraversal, @@ -255,94 +241,4 @@ enum SnapshotVisibilityFold { return applyHiddenContentHints(hints, to: kept) } - private static func projectionTransition( - frame: CGRect, - intersectsClip: Bool, - typeName: String, - hasChildren: Bool, - cursor: ProjectionCursor, - policy: Policy - ) -> ProjectionTransition { - if policy == .plainViewport { - return ProjectionTransition( - decision: ProjectionDecision( - presentationVisible: intersectsClip, - descendants: .root - ), - hiddenContentFrame: !intersectsClip ? frame : nil - ) - } - - let hasFrame = !frame.isNull && !frame.isEmpty - let geometry: Geometry = hasFrame ? .framed(intersectsClip: intersectsClip) : .geometryless - let ownsDescendants = hasChildren - && (typeName == "Cell" || scrollContainerTypeNames.contains(typeName)) - return ProjectionTransition( - decision: cursor.project( - geometry: geometry, - descendantVisibility: ownsDescendants ? .owned : .independent - ), - hiddenContentFrame: !cursor.isProjectedOut && hasFrame && !intersectsClip ? frame : nil - ) - } - - private static func shouldInclude( - _ node: RawAXNode, - visible: Bool, - interactiveOnly: Bool, - policy: Policy - ) -> Bool { - if node.parentIndex == nil { return true } - if policy == .plainViewport && interactiveOnly && !visible && node.type != "Application" { - return false - } - return visibilityExemptCarrierTypes.contains(node.type) || visible - } - - private static func rememberHiddenContentHint( - for frame: CGRect, - relativeTo scrollAnchor: (index: Int, rect: CGRect), - hints: inout [Int: (above: Bool, below: Bool)] - ) { - var hint = hints[scrollAnchor.index] ?? (above: false, below: false) - if frame.maxY <= scrollAnchor.rect.minY { - hint.above = true - } else if frame.minY >= scrollAnchor.rect.maxY { - hint.below = true - } else { - return - } - hints[scrollAnchor.index] = hint - } - - private static func applyHiddenContentHints( - _ hints: [Int: (above: Bool, below: Bool)], - to nodes: [SnapshotPresentationNode] - ) -> [SnapshotPresentationNode] { - if hints.isEmpty { return nodes } - return nodes.map { presentationNode in - let node = presentationNode.raw - guard let hint = hints[node.index] else { return presentationNode } - return SnapshotPresentationNode( - raw: RawAXNode( - index: node.index, - type: node.type, - label: node.label, - identifier: node.identifier, - value: node.value, - rect: node.rect, - enabled: node.enabled, - focused: node.focused, - selected: node.selected, - hittable: node.hittable, - depth: node.depth, - parentIndex: node.parentIndex, - hiddenContentAbove: node.hiddenContentAbove == true || hint.above ? true : nil, - hiddenContentBelow: node.hiddenContentBelow == true || hint.below ? true : nil, - actions: node.actions - ), - effectiveRect: presentationNode.effectiveRect - ) - } - } } diff --git a/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFoldProjection.swift b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFoldProjection.swift new file mode 100644 index 0000000000..af2dd9e6af --- /dev/null +++ b/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFoldProjection.swift @@ -0,0 +1,97 @@ +import CoreGraphics + +extension SnapshotVisibilityFold { + static let negligibleDecorationTolerance = 1.0 + static let visibilityExemptCarrierTypes: Set = ["Application", "Window"] + + static func projectionTransition( + frame: CGRect, + intersectsClip: Bool, + typeName: String, + hasChildren: Bool, + cursor: ProjectionCursor, + policy: Policy + ) -> ProjectionTransition { + if policy == .plainViewport { + return ProjectionTransition( + decision: ProjectionDecision( + presentationVisible: intersectsClip, + descendants: .root + ), + hiddenContentFrame: !intersectsClip ? frame : nil + ) + } + + let hasFrame = !frame.isNull && !frame.isEmpty + let geometry: Geometry = hasFrame ? .framed(intersectsClip: intersectsClip) : .geometryless + let ownsDescendants = hasChildren + && (typeName == "Cell" || scrollContainerTypeNames.contains(typeName)) + return ProjectionTransition( + decision: cursor.project( + geometry: geometry, + descendantVisibility: ownsDescendants ? .owned : .independent + ), + hiddenContentFrame: !cursor.isProjectedOut && hasFrame && !intersectsClip ? frame : nil + ) + } + + static func shouldInclude( + _ node: RawAXNode, + visible: Bool, + interactiveOnly: Bool, + policy: Policy + ) -> Bool { + if node.parentIndex == nil { return true } + if policy == .plainViewport && interactiveOnly && !visible && node.type != "Application" { + return false + } + return visibilityExemptCarrierTypes.contains(node.type) || visible + } + + static func rememberHiddenContentHint( + for frame: CGRect, + relativeTo scrollAnchor: (index: Int, rect: CGRect), + hints: inout [Int: (above: Bool, below: Bool)] + ) { + var hint = hints[scrollAnchor.index] ?? (above: false, below: false) + if frame.maxY <= scrollAnchor.rect.minY { + hint.above = true + } else if frame.minY >= scrollAnchor.rect.maxY { + hint.below = true + } else { + return + } + hints[scrollAnchor.index] = hint + } + + static func applyHiddenContentHints( + _ hints: [Int: (above: Bool, below: Bool)], + to nodes: [SnapshotPresentationNode] + ) -> [SnapshotPresentationNode] { + if hints.isEmpty { return nodes } + return nodes.map { presentationNode in + let node = presentationNode.raw + guard let hint = hints[node.index] else { return presentationNode } + return SnapshotPresentationNode( + raw: RawAXNode( + index: node.index, + type: node.type, + label: node.label, + identifier: node.identifier, + value: node.value, + rect: node.rect, + enabled: node.enabled, + focused: node.focused, + selected: node.selected, + hittable: node.hittable, + depth: node.depth, + parentIndex: node.parentIndex, + hiddenContentAbove: node.hiddenContentAbove == true || hint.above ? true : nil, + hiddenContentBelow: node.hiddenContentBelow == true || hint.below ? true : nil, + actions: node.actions + ), + effectiveRect: presentationNode.effectiveRect + ) + } + } +} diff --git a/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift b/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift new file mode 100644 index 0000000000..eea6f7e625 --- /dev/null +++ b/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift @@ -0,0 +1,79 @@ +import AgentDeviceSnapshotPresentation +import Foundation +import CoreGraphics + +private struct Input: Decodable { + struct Node: Decodable { + let index: Int + let type: String + let label: String? + let identifier: String? + let value: String? + let rect: SnapshotRect + let enabled: Bool + let focused: Bool? + let selected: Bool? + let hittable: Bool + let depth: Int + let parentIndex: Int? + let hiddenContentAbove: Bool? + let hiddenContentBelow: Bool? + } + + let projection: String + let interactiveOnly: Bool + let depth: Int? + let scope: String? + let viewport: SnapshotRect + let nodes: [Node] +} + +private struct Output: Encodable { + let nodes: [PresentedNode] +} + +private let input = try JSONDecoder().decode( + Input.self, + from: FileHandle.standardInput.readDataToEndOfFile() +) +private let options = PresentationOptions( + interactiveOnly: input.interactiveOnly, + depth: input.depth, + scope: input.scope, + raw: input.projection == CaptureHint.Projection.raw.rawValue +) +private let acquisition = SnapshotAcquisition( + hint: SnapshotPresentation.captureHint(for: options), + nodes: input.nodes.map { node in + RawAXNode( + index: node.index, + type: node.type, + label: node.label, + identifier: node.identifier, + value: node.value, + rect: node.rect, + enabled: node.enabled, + focused: node.focused, + selected: node.selected, + hittable: node.hittable, + depth: node.depth, + parentIndex: node.parentIndex, + hiddenContentAbove: node.hiddenContentAbove, + hiddenContentBelow: node.hiddenContentBelow + ) + }, + truncated: false, + effectiveDepth: nil, + viewport: input.viewport.cgRect +) +private let result = try SnapshotPresentation.present(acquisition, options: options) + ?? SnapshotPresentationResult( + nodes: [], + truncated: false, + effectiveDepth: nil, + customActions: nil, + qualityNodes: nil + ) +private let output = try JSONEncoder().encode(Output(nodes: result.nodes)) +FileHandle.standardOutput.write(output) +FileHandle.standardOutput.write(Data([0x0a])) diff --git a/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ConformanceTests.swift b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ConformanceTests.swift new file mode 100644 index 0000000000..2e526095bf --- /dev/null +++ b/apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ConformanceTests.swift @@ -0,0 +1,61 @@ +import AgentDeviceSnapshotPresentation +import CoreGraphics +import XCTest + +final class ConformanceTests: XCTestCase { + func testStandaloneHarnessUsesPresenterForAClippedRegularNode() throws { + let options = PresentationOptions( + interactiveOnly: false, + depth: nil, + scope: nil, + raw: false + ) + let result = try SnapshotPresentation.presentRegular( + SnapshotAcquisition( + hint: SnapshotPresentation.captureHint(for: options), + nodes: [ + RawAXNode( + index: 0, + type: "Application", + label: "App", + identifier: nil, + value: nil, + rect: SnapshotRect(x: 0, y: 0, width: 100, height: 100), + enabled: true, + focused: nil, + selected: nil, + hittable: false, + depth: 0, + parentIndex: nil, + hiddenContentAbove: nil, + hiddenContentBelow: nil + ), + RawAXNode( + index: 1, + type: "Button", + label: "Continue", + identifier: nil, + value: nil, + rect: SnapshotRect(x: 80, y: 10, width: 40, height: 20), + enabled: true, + focused: nil, + selected: nil, + hittable: true, + depth: 1, + parentIndex: 0, + hiddenContentAbove: nil, + hiddenContentBelow: nil + ), + ], + truncated: false, + effectiveDepth: nil, + viewport: CGRect(x: 0, y: 0, width: 100, height: 100) + ), + options: options, + policy: .cursorProjected + ) + + XCTAssertEqual(result.nodes.map(\.label), ["App", "Continue"]) + XCTAssertEqual(result.nodes[1].rect, SnapshotRect(x: 80, y: 10, width: 20, height: 20)) + } +} diff --git a/scripts/package-apple-runner-source.mjs b/scripts/package-apple-runner-source.mjs index 5c703a3af1..9591098331 100644 --- a/scripts/package-apple-runner-source.mjs +++ b/scripts/package-apple-runner-source.mjs @@ -6,6 +6,17 @@ import { fileURLToPath } from 'node:url'; const UNIT_TEST_CONDITION = 'AGENT_DEVICE_RUNNER_UNIT_TESTS'; const SOURCE_DIR = path.join('apple', 'runner'); const OUTPUT_DIR = path.join('dist', 'apple', 'runner'); +const SNAPSHOT_PRESENTATION_SOURCE_DIR = path.join('apple', 'snapshot-presentation'); +const SNAPSHOT_PRESENTATION_OUTPUT_DIR = path.join('dist', 'apple', 'snapshot-presentation'); +const SNAPSHOT_PRESENTATION_RUNNER_MANIFEST = 'Package.runner.swift'; +const SNAPSHOT_PRESENTATION_DEVELOPMENT_DIR_NAMES = new Set([ + 'Tests', + 'SnapshotPresentationConformance', + '.build', + '.swiftpm', + 'UnitTests', + 'xcuserdata', +]); // Packaged-runner locations from before the apple-runner/ -> apple/runner/ move. `dist` ships // wholesale, so a stale tree left by an older build/checkout would double-ship into the npm // package (and inflate the bundle-size diff, which packages the base then the PR into one dist). @@ -44,14 +55,49 @@ function packageAppleRunnerSource(options = {}) { }; processDirectory(sourceRoot, options.checkOnly ? undefined : outputRoot, '', summary); + packageSnapshotPresentationSource(root, options, summary); return summary; } +function packageSnapshotPresentationSource(root, options, summary) { + const sourceRoot = path.join(root, SNAPSHOT_PRESENTATION_SOURCE_DIR); + if (!fs.existsSync(sourceRoot)) { + return; + } + const outputRoot = path.join(root, SNAPSHOT_PRESENTATION_OUTPUT_DIR); + prepareSnapshotPresentationOutput(outputRoot, options.checkOnly); + const manifestSource = requireSnapshotPresentationManifest(sourceRoot); + processDirectory(sourceRoot, options.checkOnly ? undefined : outputRoot, '', summary, { + validateSwift: false, + skipDirectoryNames: SNAPSHOT_PRESENTATION_DEVELOPMENT_DIR_NAMES, + skipFilePaths: new Set(['Package.swift', SNAPSHOT_PRESENTATION_RUNNER_MANIFEST]), + }); + copySnapshotPresentationManifest(manifestSource, outputRoot, summary, options.checkOnly); +} + +function prepareSnapshotPresentationOutput(outputRoot, checkOnly) { + if (checkOnly) return; + fs.rmSync(outputRoot, { recursive: true, force: true }); +} + +function requireSnapshotPresentationManifest(sourceRoot) { + const manifestSource = path.join(sourceRoot, SNAPSHOT_PRESENTATION_RUNNER_MANIFEST); + if (fs.existsSync(manifestSource)) return manifestSource; + throw new Error(`Apple snapshot presentation runner manifest not found at ${manifestSource}`); +} + +function copySnapshotPresentationManifest(manifestSource, outputRoot, summary, checkOnly) { + if (checkOnly) return; + fs.copyFileSync(manifestSource, path.join(outputRoot, 'Package.swift')); + summary.copiedFiles += 1; +} + function prepareOutput(root, outputRoot, checkOnly) { if (checkOnly) { return; } fs.rmSync(outputRoot, { recursive: true, force: true }); + fs.rmSync(path.join(root, SNAPSHOT_PRESENTATION_OUTPUT_DIR), { recursive: true, force: true }); for (const legacyDir of LEGACY_OUTPUT_DIRS) { fs.rmSync(path.join(root, legacyDir), { recursive: true, force: true }); } @@ -101,63 +147,72 @@ function consumeSkippedConditionalLine(state, line) { } } -function processDirectory(sourceDir, outputDir, relativeDir, summary) { +function processDirectory(sourceDir, outputDir, relativeDir, summary, options = {}) { if (outputDir) { fs.mkdirSync(outputDir, { recursive: true }); } const entries = fs.readdirSync(sourceDir, { withFileTypes: true }); for (const entry of entries) { - processDirectoryEntry(entry, sourceDir, outputDir, relativeDir, summary); + processDirectoryEntry(entry, sourceDir, outputDir, relativeDir, summary, options); } } -function processDirectoryEntry(entry, sourceDir, outputDir, relativeDir, summary) { +function processDirectoryEntry(entry, sourceDir, outputDir, relativeDir, summary, options) { const relativePath = path.join(relativeDir, entry.name); - if (shouldSkipEntry(entry, relativePath)) { + if (shouldSkipEntry(entry, relativePath, options)) { return; } - processIncludedEntry(entry, sourceDir, outputDir, relativePath, summary); + processIncludedEntry(entry, sourceDir, outputDir, relativePath, summary, options); } -function processIncludedEntry(entry, sourceDir, outputDir, relativePath, summary) { +function processIncludedEntry(entry, sourceDir, outputDir, relativePath, summary, options) { const sourcePath = path.join(sourceDir, entry.name); const outputPath = outputDir ? path.join(outputDir, entry.name) : undefined; if (entry.isDirectory()) { - processDirectory(sourcePath, outputPath, relativePath, summary); + processDirectory(sourcePath, outputPath, relativePath, summary, options); return; } if (!entry.isFile()) { return; } - processFile(sourcePath, outputPath, relativePath, summary); + processFile(sourcePath, outputPath, relativePath, summary, options); } -function processFile(sourcePath, outputPath, relativePath, summary) { +function processFile(sourcePath, outputPath, relativePath, summary, options) { if (outputPath) { - copyFile(sourcePath, outputPath, relativePath, summary); + copyFile(sourcePath, outputPath, relativePath, summary, options); return; } - validateFile(sourcePath, relativePath, summary); + validateFile(sourcePath, relativePath, summary, options); } -function copyFile(sourcePath, outputPath, relativePath, summary) { +function copyFile(sourcePath, outputPath, relativePath, summary, options) { if (path.extname(sourcePath) !== '.swift') { fs.copyFileSync(sourcePath, outputPath); summary.copiedFiles += 1; return; } + if (options.validateSwift === false) { + fs.copyFileSync(sourcePath, outputPath); + summary.copiedFiles += 1; + return; + } + const stripped = validateSwiftFile(sourcePath, relativePath, summary); fs.writeFileSync(outputPath, stripped.contents); summary.copiedFiles += 1; } -function validateFile(sourcePath, relativePath, summary) { +function validateFile(sourcePath, relativePath, summary, options) { if (path.extname(sourcePath) !== '.swift') { return undefined; } + if (options.validateSwift === false) { + return undefined; + } return validateSwiftFile(sourcePath, relativePath, summary); } @@ -186,16 +241,21 @@ function assertNoShippedTestMethods(strippedContents, relativePath) { } } -function shouldSkipEntry(entry, relativePath) { - return shouldSkipDirectory(entry) || shouldSkipFile(entry, relativePath); +function shouldSkipEntry(entry, relativePath, options) { + return shouldSkipDirectory(entry, options) || shouldSkipFile(entry, relativePath, options); } -function shouldSkipDirectory(entry) { - return entry.isDirectory() && SKIPPED_DIR_NAMES.has(entry.name); +function shouldSkipDirectory(entry, options) { + return entry.isDirectory() && (options.skipDirectoryNames ?? SKIPPED_DIR_NAMES).has(entry.name); } -function shouldSkipFile(entry, relativePath) { - return entry.isFile() && (isXcodeUserStateFile(entry) || isSkippedRootFile(entry, relativePath)); +function shouldSkipFile(entry, relativePath, options) { + return ( + entry.isFile() && + (isXcodeUserStateFile(entry) || + isSkippedRootFile(entry, relativePath) || + options.skipFilePaths?.has(relativePath) === true) + ); } function isXcodeUserStateFile(entry) { diff --git a/src/__tests__/apple-runner-package-source.test.ts b/src/__tests__/apple-runner-package-source.test.ts index b9d86012db..880352e107 100644 --- a/src/__tests__/apple-runner-package-source.test.ts +++ b/src/__tests__/apple-runner-package-source.test.ts @@ -41,6 +41,26 @@ test('package apple runner source strips unit-test blocks without mutating check path.join(root, 'dist/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj'), ), ); + assert.ok(fs.existsSync(path.join(root, 'dist/apple/snapshot-presentation/Package.swift'))); + assert.equal( + fs.readFileSync(path.join(root, 'dist/apple/snapshot-presentation/Package.swift'), 'utf8'), + 'runner package\n', + ); + assert.ok( + fs.existsSync( + path.join( + root, + 'dist/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/Package.swift', + ), + ), + ); + assert.equal( + fs.existsSync( + path.join(root, 'dist/apple/snapshot-presentation/Sources/SnapshotPresentationConformance'), + ), + false, + ); + assert.equal(fs.existsSync(path.join(root, 'dist/apple/snapshot-presentation/Tests')), false); assert.equal(fs.existsSync(path.join(root, 'dist/apple/runner/README.md')), false); assert.equal(fs.existsSync(path.join(root, 'dist/apple/runner/.build/cache.txt')), false); assert.equal( @@ -192,6 +212,32 @@ test('apple runner tree snapshot capture stays on the main queue', () => { assert.match(boundedCapture, /captureSnapshotRoot\(element\)/); }); +test('runner uses the shared presenter without a local facade or model aliases', () => { + const runnerRoot = path.join(repoRoot, 'apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests'); + assert.equal( + fs.existsSync(path.join(runnerRoot, 'RunnerTests+SnapshotPresentation.swift')), + false, + 'the runner must not recreate a local SnapshotPresentation facade', + ); + assert.equal( + fs.existsSync(path.join(runnerRoot, 'RunnerTests+SnapshotPresentationModels.swift')), + false, + 'the runner must not recreate shared presenter type aliases', + ); + + const sourceFiles = fs + .readdirSync(runnerRoot) + .filter((entry) => entry.endsWith('.swift')) + .map((entry) => fs.readFileSync(path.join(runnerRoot, entry), 'utf8')) + .join('\n'); + assert.doesNotMatch(sourceFiles, /enum SnapshotPresentation\s*\{/); + assert.doesNotMatch( + sourceFiles, + /typealias (?:RawAXNode|CaptureHint|SnapshotAcquisition|PresentedNode)\s*=/, + ); + assert.match(sourceFiles, /import AgentDeviceSnapshotPresentation/); +}); + function writeStripFixtureTree(root: string): void { writeFixtureFile(root, 'apple/runner/README.md', 'developer docs\n'); writeFixtureFile(root, 'apple/runner/.build/cache.txt', 'cache\n'); @@ -205,6 +251,23 @@ function writeStripFixtureTree(root: string): void { 'apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcuserdata/user.xcuserstate', 'state\n', ); + writeFixtureFile(root, 'apple/snapshot-presentation/Package.swift', 'package\n'); + writeFixtureFile(root, 'apple/snapshot-presentation/Package.runner.swift', 'runner package\n'); + writeFixtureFile( + root, + 'apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/Package.swift', + 'package source\n', + ); + writeFixtureFile( + root, + 'apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift', + 'development harness\n', + ); + writeFixtureFile( + root, + 'apple/snapshot-presentation/Tests/AgentDeviceSnapshotPresentationTests/ConformanceTests.swift', + 'development tests\n', + ); writeFixtureFile( root, 'apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Feature.swift',