Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,16 @@ jobs:
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSnapshotTraversalIdentityPreservesSameOriginNodesWithDifferentBounds \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSnapshotPresentationPreservesCurrentWireShape \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSnapshotPresentationOwnsBackendNeutralEligibility \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSnapshotPresentationOwnsScopeAndRelativeDepth \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSnapshotScopePolicyMatchesGoldenParityTable \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testFlatSnapshotProjectionMatchesElementReverseScrollCapture \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXDepthLimitedRequiresEveryFrontierResolved \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testDeepExtensionCountsMissedFrontiers \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPreferredPrivateAXBackendPlansAsPenalized \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXRegularPresentationProjectsToViewportAndKeepsScrollHint \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXPresentationKeepsOffscreenSubtreeExcludedWhenChildFramesAreClamped \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXGeometrylessSemanticsAreNeverActionableOrScrollContexts \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXScopeSelectsSubtreeNotMatchingLabels \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXAcquisitionDoesNotInterpretScope \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXInteractiveFiltersLoginLikeHiddenDrawer \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testDecodedPreferredBackendReachesOptionsAndApplicablePlan \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSparsePayloadReasonMatrix \
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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<Node>(
fromPreorder nodes: [Node],
scope: String?,
depth: (Node) -> Int,
semanticValues: (Node) -> [String?],
subtreeContributes: (Range<Int>) -> 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<Node>(
from start: Int,
in nodes: [Node],
depth: (Node) -> Int
) -> Range<Int> {
let rootDepth = depth(nodes[start])
var end = start + 1
while end < nodes.count, depth(nodes[end]) > rootDepth {
end += 1
}
return start..<end
}
}

#if AGENT_DEVICE_RUNNER_UNIT_TESTS
private struct SnapshotScopeFixture: Decodable {
struct Node: Decodable {
let depth: Int
let label: String?
let identifier: String?
let value: String?
let presented: Bool?
}

let name: String
let scope: String
let nodes: [Node]
let expectedSubtreeIndexes: [Int]
}

extension RunnerTests {
func testSnapshotScopePolicyMatchesGoldenParityTable() throws {
// Non-vacuity: label-only semantic values fail the identifier-only and value-only fixtures.
let fixtureURL = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // AgentDeviceRunnerUITests
.deletingLastPathComponent() // AgentDeviceRunner
.deletingLastPathComponent() // runner
.deletingLastPathComponent() // apple
.deletingLastPathComponent() // repo root
.appendingPathComponent("contracts")
.appendingPathComponent("fixtures")
.appendingPathComponent("snapshot-scope-policy.json")
let cases = try JSONDecoder().decode(
[SnapshotScopeFixture].self,
from: Data(contentsOf: fixtureURL)
)
XCTAssertFalse(cases.isEmpty, "parity table must not be empty")

for fixture in cases {
let selected = SnapshotScopePolicy.select(
fromPreorder: fixture.nodes,
scope: fixture.scope,
depth: \.depth,
semanticValues: { [$0.label, $0.identifier, $0.value] },
subtreeContributes: { range in
range.contains { fixture.nodes[$0].presented != false }
}
)
let actual: [Int]
switch selected {
case .unscoped:
actual = Array(fixture.nodes.indices)
case .missing:
actual = []
case .matched(let start):
actual = Array(
SnapshotScopePolicy.subtreeRange(
from: start,
in: fixture.nodes,
depth: \.depth
)
)
}
XCTAssertEqual(actual, fixture.expectedSubtreeIndexes, fixture.name)
}
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,7 @@ extension RunnerTests {
XCTAssertNil(nodes.first { $0.label == "Compose" }?.actions)
}

func testPrivateAXScopeSelectsSubtreeNotMatchingLabels() {
func testPrivateAXAcquisitionDoesNotInterpretScope() {
let tree: [String: Any] = [
"type": 1, "label": "App",
"children": [
Expand Down Expand Up @@ -675,7 +675,7 @@ extension RunnerTests {
XCTAssertTrue(labels.contains("homeScreen"))
// Descendants of the matched scope are included even when they do not contain the text.
XCTAssertTrue(labels.contains("Post body without the scope text"))
XCTAssertFalse(labels.contains("unrelated sibling"))
XCTAssertTrue(labels.contains("unrelated sibling"))
}

func testPrivateAXInteractiveFiltersLoginLikeHiddenDrawer() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,11 @@ import XCTest

struct FlatSnapshotFilterNode {
let isRoot: Bool
let label: String
let identifier: String
let valueText: String?
let visible: Bool

func matchesScope(_ scope: String) -> Bool {
let haystack = [label, identifier, valueText ?? ""].joined(separator: "\n")
return haystack.localizedCaseInsensitiveContains(scope)
}
}

struct FlatSnapshotFilterDecision {
let include: Bool
let insideMatchedScope: Bool
}

enum FlatSnapshotVisibilityPolicy {
Expand Down Expand Up @@ -120,24 +111,11 @@ extension RunnerTests {
func flatSnapshotFilterDecision(
_ node: FlatSnapshotFilterNode,
options: PresentationOptions,
visibilityPolicy: FlatSnapshotVisibilityPolicy,
insideMatchedScope: Bool
visibilityPolicy: FlatSnapshotVisibilityPolicy
) -> FlatSnapshotFilterDecision {
let scope = options.scope?.trimmingCharacters(in: .whitespacesAndNewlines)
let scopeActive = scope?.isEmpty == false
let matchesScope: Bool
if scopeActive, let scope {
matchesScope = node.matchesScope(scope)
} else {
matchesScope = false
}
let nowInsideScope = insideMatchedScope || matchesScope

let include: Bool
if node.isRoot {
include = true
} else if scopeActive && !nowInsideScope {
include = false
} else if !node.visible
&& (options.interactiveOnly || visibilityPolicy == .viewportProjected)
{
Expand All @@ -146,7 +124,7 @@ extension RunnerTests {
include = true
}

return FlatSnapshotFilterDecision(include: include, insideMatchedScope: nowInsideScope)
return FlatSnapshotFilterDecision(include: include)
}

func privateAXInteractiveCandidate(rawElementType: Int) -> Bool {
Expand Down Expand Up @@ -309,123 +287,61 @@ extension RunnerTests {
func testFlatSnapshotFilterDecisionMatrixCoversOptions() {
let visibleContent = FlatSnapshotFilterNode(
isRoot: false,
label: "Welcome back",
identifier: "",
valueText: nil,
visible: true
)
let hiddenInteractive = FlatSnapshotFilterNode(
isRoot: false,
label: "Hidden menu",
identifier: "",
valueText: nil,
visible: false
)
let decorative = FlatSnapshotFilterNode(
isRoot: false,
label: "",
identifier: "",
valueText: nil,
visible: true
)
let hiddenRoot = FlatSnapshotFilterNode(
isRoot: true,
label: "App",
identifier: "",
valueText: nil,
visible: false
)

XCTAssertTrue(
flatSnapshotFilterDecision(
visibleContent,
options: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false),
visibilityPolicy: .interactiveOnly,
insideMatchedScope: false
visibilityPolicy: .interactiveOnly
).include
)
XCTAssertFalse(
flatSnapshotFilterDecision(
hiddenInteractive,
options: PresentationOptions(interactiveOnly: true, depth: nil, scope: nil, raw: false),
visibilityPolicy: .interactiveOnly,
insideMatchedScope: false
visibilityPolicy: .interactiveOnly
).include
)
XCTAssertFalse(
flatSnapshotFilterDecision(
hiddenInteractive,
options: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false),
visibilityPolicy: .viewportProjected,
insideMatchedScope: false
visibilityPolicy: .viewportProjected
).include
)
XCTAssertTrue(
flatSnapshotFilterDecision(
hiddenInteractive,
options: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false),
visibilityPolicy: .interactiveOnly,
insideMatchedScope: false
visibilityPolicy: .interactiveOnly
).include
)
XCTAssertTrue(
flatSnapshotFilterDecision(
hiddenRoot,
options: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false),
visibilityPolicy: .viewportProjected,
insideMatchedScope: false
visibilityPolicy: .viewportProjected
).include
)
XCTAssertTrue(
flatSnapshotFilterDecision(
decorative,
options: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false),
visibilityPolicy: .interactiveOnly,
insideMatchedScope: false
).include
)
}

func testFlatSnapshotFilterDecisionCarriesSubtreeScopeState() {
let scopeRoot = FlatSnapshotFilterNode(
isRoot: false,
label: "",
identifier: "homeScreen",
valueText: nil,
visible: true
)
let unmatchedDescendant = FlatSnapshotFilterNode(
isRoot: false,
label: "Post body without the scope text",
identifier: "",
valueText: nil,
visible: true
)
let options = PresentationOptions(interactiveOnly: false, depth: nil, scope: "homeScreen", raw: false)

let rootDecision = flatSnapshotFilterDecision(
scopeRoot,
options: options,
visibilityPolicy: .interactiveOnly,
insideMatchedScope: false
)
XCTAssertTrue(rootDecision.include)
XCTAssertTrue(rootDecision.insideMatchedScope)

XCTAssertTrue(
flatSnapshotFilterDecision(
unmatchedDescendant,
options: options,
visibilityPolicy: .interactiveOnly,
insideMatchedScope: rootDecision.insideMatchedScope
).include
)
XCTAssertFalse(
flatSnapshotFilterDecision(
unmatchedDescendant,
options: options,
visibilityPolicy: .interactiveOnly,
insideMatchedScope: false
visibilityPolicy: .interactiveOnly
).include
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ extension RunnerTests {
var nodes: [RawAXNode] = []
var hints: [Int: (above: Bool, below: Bool)] = [:]
appendPrivateAXNode(rawRoot, to: &nodes, hints: &hints, options: options, viewport: viewport,
depth: 0, parentIndex: nil, insideMatchedScope: false, scrollContext: nil,
depth: 0, parentIndex: nil, scrollContext: nil,
projectionCursor: .root)
return applyHiddenContentHints(hints, to: nodes)
}

private func appendPrivateAXNode(_ raw: [String: Any], to nodes: inout [RawAXNode],
hints: inout [Int: (above: Bool, below: Bool)], options: PresentationOptions, viewport: CGRect,
depth: Int, parentIndex: Int?, insideMatchedScope: Bool,
depth: Int, parentIndex: Int?,
scrollContext: (index: Int, rect: CGRect)?, projectionCursor: FlatSnapshotProjectionCursor)
{
if let limit = options.depth, depth > limit { return }
Expand Down Expand Up @@ -51,10 +51,8 @@ extension RunnerTests {
let projection = projectionTransition.decision
let presentationVisible = projection.presentationVisible && !negligibleDecoration
let decision = flatSnapshotFilterDecision(
FlatSnapshotFilterNode(isRoot: parentIndex == nil, label: label, identifier: identifier,
valueText: value.isEmpty ? nil : value, visible: presentationVisible),
options: options, visibilityPolicy: .viewportProjected,
insideMatchedScope: insideMatchedScope)
FlatSnapshotFilterNode(isRoot: parentIndex == nil, visible: presentationVisible),
options: options, visibilityPolicy: .viewportProjected)
let include = decision.include

if let hiddenFrame = projectionTransition.hiddenContentFrame, let scrollContext {
Expand Down Expand Up @@ -88,7 +86,7 @@ extension RunnerTests {
for child in children {
appendPrivateAXNode(child, to: &nodes, hints: &hints, options: options, viewport: viewport,
depth: depth + 1, parentIndex: currentIndex,
insideMatchedScope: decision.insideMatchedScope, scrollContext: nextScrollContext,
scrollContext: nextScrollContext,
projectionCursor: projection.descendants)
}
}
Expand Down
Loading
Loading