Skip to content

Commit bd59d47

Browse files
committed
feat(snapshot): move scope into presentation
Use one preorder label/identifier/value policy across Swift and TypeScript, keep scoped iOS acquisition conservative, and remove the daemon's second scope pass. Non-vacuity: label-only matching failed identifier/value parity fixtures; Android pass-through failed its boundary test; disconnecting Swift applyScope produced eight scope/depth/projection failures.
1 parent 107e5c8 commit bd59d47

23 files changed

Lines changed: 786 additions & 455 deletions

.github/workflows/ios.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,16 @@ jobs:
119119
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSnapshotTraversalIdentityPreservesSameOriginNodesWithDifferentBounds \
120120
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSnapshotPresentationPreservesCurrentWireShape \
121121
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSnapshotPresentationOwnsBackendNeutralEligibility \
122+
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSnapshotPresentationOwnsScopeAndRelativeDepth \
123+
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSnapshotScopePolicyMatchesGoldenParityTable \
122124
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testFlatSnapshotProjectionMatchesElementReverseScrollCapture \
123125
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXDepthLimitedRequiresEveryFrontierResolved \
124126
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testDeepExtensionCountsMissedFrontiers \
125127
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPreferredPrivateAXBackendPlansAsPenalized \
126128
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXRegularPresentationProjectsToViewportAndKeepsScrollHint \
127129
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXPresentationKeepsOffscreenSubtreeExcludedWhenChildFramesAreClamped \
128130
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXGeometrylessSemanticsAreNeverActionableOrScrollContexts \
129-
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXScopeSelectsSubtreeNotMatchingLabels \
131+
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXAcquisitionDoesNotInterpretScope \
130132
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXInteractiveFiltersLoginLikeHiddenDrawer \
131133
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testDecodedPreferredBackendReachesOptionsAndApplicablePlan \
132134
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSparsePayloadReasonMatrix \
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import Foundation
2+
3+
enum SnapshotScopeSelection: Equatable {
4+
case unscoped
5+
case matched(Int)
6+
case missing
7+
}
8+
9+
/// Cross-runtime snapshot scope specification.
10+
///
11+
/// A non-empty scope selects the first node in presentation preorder whose label, identifier, or
12+
/// value contains the trimmed query case-insensitively. Missing matches publish an empty projection.
13+
enum SnapshotScopePolicy {
14+
static func select<Node>(
15+
fromPreorder nodes: [Node],
16+
scope: String?,
17+
semanticValues: (Node) -> [String?]
18+
) -> SnapshotScopeSelection {
19+
guard let query = normalized(scope) else { return .unscoped }
20+
for (index, node) in nodes.enumerated() {
21+
if semanticValues(node).contains(where: { value in
22+
value?.lowercased().contains(query) == true
23+
}) {
24+
return .matched(index)
25+
}
26+
}
27+
return .missing
28+
}
29+
30+
static func isActive(_ scope: String?) -> Bool {
31+
normalized(scope) != nil
32+
}
33+
34+
private static func normalized(_ scope: String?) -> String? {
35+
guard let query = scope?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty else {
36+
return nil
37+
}
38+
return query.lowercased()
39+
}
40+
}
41+
42+
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
43+
private struct SnapshotScopeFixture: Decodable {
44+
struct Node: Decodable {
45+
let key: String
46+
let label: String?
47+
let identifier: String?
48+
let value: String?
49+
let children: [Node]
50+
51+
var preorder: [Node] {
52+
[self] + children.flatMap(\.preorder)
53+
}
54+
}
55+
56+
let name: String
57+
let scope: String
58+
let roots: [Node]
59+
let selection: String
60+
}
61+
62+
extension RunnerTests {
63+
func testSnapshotScopePolicyMatchesGoldenParityTable() throws {
64+
// Non-vacuity: label-only semantic values fail the identifier-only and value-only fixtures.
65+
let fixtureURL = URL(fileURLWithPath: #filePath)
66+
.deletingLastPathComponent() // AgentDeviceRunnerUITests
67+
.deletingLastPathComponent() // AgentDeviceRunner
68+
.deletingLastPathComponent() // runner
69+
.deletingLastPathComponent() // apple
70+
.deletingLastPathComponent() // repo root
71+
.appendingPathComponent("contracts")
72+
.appendingPathComponent("fixtures")
73+
.appendingPathComponent("snapshot-scope-policy.json")
74+
let cases = try JSONDecoder().decode(
75+
[SnapshotScopeFixture].self,
76+
from: Data(contentsOf: fixtureURL)
77+
)
78+
XCTAssertFalse(cases.isEmpty, "parity table must not be empty")
79+
80+
for fixture in cases {
81+
let nodes = fixture.roots.flatMap(\.preorder)
82+
let selected = SnapshotScopePolicy.select(
83+
fromPreorder: nodes,
84+
scope: fixture.scope,
85+
semanticValues: { [$0.label, $0.identifier, $0.value] }
86+
)
87+
let actual: String
88+
switch selected {
89+
case .unscoped:
90+
actual = "unscoped"
91+
case .missing:
92+
actual = "missing"
93+
case .matched(let index):
94+
actual = nodes[index].key
95+
}
96+
XCTAssertEqual(actual, fixture.selection, fixture.name)
97+
}
98+
}
99+
}
100+
#endif

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -647,7 +647,7 @@ extension RunnerTests {
647647
XCTAssertNil(nodes.first { $0.label == "Compose" }?.actions)
648648
}
649649

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

681681
func testPrivateAXInteractiveFiltersLoginLikeHiddenDrawer() {

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift

Lines changed: 8 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,11 @@ import XCTest
22

33
struct FlatSnapshotFilterNode {
44
let isRoot: Bool
5-
let label: String
6-
let identifier: String
7-
let valueText: String?
85
let visible: Bool
9-
10-
func matchesScope(_ scope: String) -> Bool {
11-
let haystack = [label, identifier, valueText ?? ""].joined(separator: "\n")
12-
return haystack.localizedCaseInsensitiveContains(scope)
13-
}
146
}
157

168
struct FlatSnapshotFilterDecision {
179
let include: Bool
18-
let insideMatchedScope: Bool
1910
}
2011

2112
enum FlatSnapshotVisibilityPolicy {
@@ -120,24 +111,11 @@ extension RunnerTests {
120111
func flatSnapshotFilterDecision(
121112
_ node: FlatSnapshotFilterNode,
122113
options: PresentationOptions,
123-
visibilityPolicy: FlatSnapshotVisibilityPolicy,
124-
insideMatchedScope: Bool
114+
visibilityPolicy: FlatSnapshotVisibilityPolicy
125115
) -> FlatSnapshotFilterDecision {
126-
let scope = options.scope?.trimmingCharacters(in: .whitespacesAndNewlines)
127-
let scopeActive = scope?.isEmpty == false
128-
let matchesScope: Bool
129-
if scopeActive, let scope {
130-
matchesScope = node.matchesScope(scope)
131-
} else {
132-
matchesScope = false
133-
}
134-
let nowInsideScope = insideMatchedScope || matchesScope
135-
136116
let include: Bool
137117
if node.isRoot {
138118
include = true
139-
} else if scopeActive && !nowInsideScope {
140-
include = false
141119
} else if !node.visible
142120
&& (options.interactiveOnly || visibilityPolicy == .viewportProjected)
143121
{
@@ -146,7 +124,7 @@ extension RunnerTests {
146124
include = true
147125
}
148126

149-
return FlatSnapshotFilterDecision(include: include, insideMatchedScope: nowInsideScope)
127+
return FlatSnapshotFilterDecision(include: include)
150128
}
151129

152130
func privateAXInteractiveCandidate(rawElementType: Int) -> Bool {
@@ -309,123 +287,61 @@ extension RunnerTests {
309287
func testFlatSnapshotFilterDecisionMatrixCoversOptions() {
310288
let visibleContent = FlatSnapshotFilterNode(
311289
isRoot: false,
312-
label: "Welcome back",
313-
identifier: "",
314-
valueText: nil,
315290
visible: true
316291
)
317292
let hiddenInteractive = FlatSnapshotFilterNode(
318293
isRoot: false,
319-
label: "Hidden menu",
320-
identifier: "",
321-
valueText: nil,
322294
visible: false
323295
)
324296
let decorative = FlatSnapshotFilterNode(
325297
isRoot: false,
326-
label: "",
327-
identifier: "",
328-
valueText: nil,
329298
visible: true
330299
)
331300
let hiddenRoot = FlatSnapshotFilterNode(
332301
isRoot: true,
333-
label: "App",
334-
identifier: "",
335-
valueText: nil,
336302
visible: false
337303
)
338304

339305
XCTAssertTrue(
340306
flatSnapshotFilterDecision(
341307
visibleContent,
342308
options: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false),
343-
visibilityPolicy: .interactiveOnly,
344-
insideMatchedScope: false
309+
visibilityPolicy: .interactiveOnly
345310
).include
346311
)
347312
XCTAssertFalse(
348313
flatSnapshotFilterDecision(
349314
hiddenInteractive,
350315
options: PresentationOptions(interactiveOnly: true, depth: nil, scope: nil, raw: false),
351-
visibilityPolicy: .interactiveOnly,
352-
insideMatchedScope: false
316+
visibilityPolicy: .interactiveOnly
353317
).include
354318
)
355319
XCTAssertFalse(
356320
flatSnapshotFilterDecision(
357321
hiddenInteractive,
358322
options: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false),
359-
visibilityPolicy: .viewportProjected,
360-
insideMatchedScope: false
323+
visibilityPolicy: .viewportProjected
361324
).include
362325
)
363326
XCTAssertTrue(
364327
flatSnapshotFilterDecision(
365328
hiddenInteractive,
366329
options: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false),
367-
visibilityPolicy: .interactiveOnly,
368-
insideMatchedScope: false
330+
visibilityPolicy: .interactiveOnly
369331
).include
370332
)
371333
XCTAssertTrue(
372334
flatSnapshotFilterDecision(
373335
hiddenRoot,
374336
options: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false),
375-
visibilityPolicy: .viewportProjected,
376-
insideMatchedScope: false
337+
visibilityPolicy: .viewportProjected
377338
).include
378339
)
379340
XCTAssertTrue(
380341
flatSnapshotFilterDecision(
381342
decorative,
382343
options: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false),
383-
visibilityPolicy: .interactiveOnly,
384-
insideMatchedScope: false
385-
).include
386-
)
387-
}
388-
389-
func testFlatSnapshotFilterDecisionCarriesSubtreeScopeState() {
390-
let scopeRoot = FlatSnapshotFilterNode(
391-
isRoot: false,
392-
label: "",
393-
identifier: "homeScreen",
394-
valueText: nil,
395-
visible: true
396-
)
397-
let unmatchedDescendant = FlatSnapshotFilterNode(
398-
isRoot: false,
399-
label: "Post body without the scope text",
400-
identifier: "",
401-
valueText: nil,
402-
visible: true
403-
)
404-
let options = PresentationOptions(interactiveOnly: false, depth: nil, scope: "homeScreen", raw: false)
405-
406-
let rootDecision = flatSnapshotFilterDecision(
407-
scopeRoot,
408-
options: options,
409-
visibilityPolicy: .interactiveOnly,
410-
insideMatchedScope: false
411-
)
412-
XCTAssertTrue(rootDecision.include)
413-
XCTAssertTrue(rootDecision.insideMatchedScope)
414-
415-
XCTAssertTrue(
416-
flatSnapshotFilterDecision(
417-
unmatchedDescendant,
418-
options: options,
419-
visibilityPolicy: .interactiveOnly,
420-
insideMatchedScope: rootDecision.insideMatchedScope
421-
).include
422-
)
423-
XCTAssertFalse(
424-
flatSnapshotFilterDecision(
425-
unmatchedDescendant,
426-
options: options,
427-
visibilityPolicy: .interactiveOnly,
428-
insideMatchedScope: false
344+
visibilityPolicy: .interactiveOnly
429345
).include
430346
)
431347
}

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+PrivateAXPresentation.swift

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ extension RunnerTests {
99
var nodes: [RawAXNode] = []
1010
var hints: [Int: (above: Bool, below: Bool)] = [:]
1111
appendPrivateAXNode(rawRoot, to: &nodes, hints: &hints, options: options, viewport: viewport,
12-
depth: 0, parentIndex: nil, insideMatchedScope: false, scrollContext: nil,
12+
depth: 0, parentIndex: nil, scrollContext: nil,
1313
projectionCursor: .root)
1414
return applyHiddenContentHints(hints, to: nodes)
1515
}
1616

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

6058
if let hiddenFrame = projectionTransition.hiddenContentFrame, let scrollContext {
@@ -88,7 +86,7 @@ extension RunnerTests {
8886
for child in children {
8987
appendPrivateAXNode(child, to: &nodes, hints: &hints, options: options, viewport: viewport,
9088
depth: depth + 1, parentIndex: currentIndex,
91-
insideMatchedScope: decision.insideMatchedScope, scrollContext: nextScrollContext,
89+
scrollContext: nextScrollContext,
9290
projectionCursor: projection.descendants)
9391
}
9492
}

0 commit comments

Comments
 (0)