Skip to content

Commit 58aebf3

Browse files
committed
fix: bound compact iOS snapshots on broken AX trees
1 parent 8f9f7fc commit 58aebf3

4 files changed

Lines changed: 216 additions & 63 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,18 @@ extension RunnerTests {
88
}
99

1010
func resolveAlert(app activeApp: XCUIApplication) -> RunnerAlert? {
11+
#if !os(macOS)
12+
if let systemModal = firstBlockingSystemModal(in: springboard) {
13+
return runnerAlert(root: systemModal, ownerApp: springboard)
14+
}
15+
#endif
1116
if let alert = firstExistingElement(in: activeApp.alerts.allElementsBoundByIndex) {
1217
return runnerAlert(root: alert, ownerApp: activeApp)
1318
}
1419
if let popup = firstDismissPopupWindow(in: activeApp) {
1520
return runnerAlert(root: popup, ownerApp: activeApp)
1621
}
17-
#if os(macOS)
18-
return nil
19-
#else
20-
if let systemModal = firstBlockingSystemModal(in: springboard) {
21-
return runnerAlert(root: systemModal, ownerApp: springboard)
22-
}
2322
return nil
24-
#endif
2523
}
2624

2725
func handleAlert(_ alert: RunnerAlert, action: String) -> Response {
@@ -33,6 +31,27 @@ extension RunnerTests {
3331
if let response = unsupportedResponse(for: outcome) {
3432
return response
3533
}
34+
sleepFor(0.2)
35+
if alertStillVisible(alert, actionButtonLabel: button.label) {
36+
let frame = button.frame
37+
if !frame.isNull && !frame.isEmpty {
38+
let coordinateOutcome = tapAt(app: alert.ownerApp, x: frame.midX, y: frame.midY)
39+
if let response = unsupportedResponse(for: coordinateOutcome) {
40+
return response
41+
}
42+
sleepFor(0.2)
43+
}
44+
}
45+
if alertStillVisible(alert, actionButtonLabel: button.label) {
46+
return Response(
47+
ok: false,
48+
error: ErrorPayload(
49+
code: "INTERACTION_FAILED",
50+
message: "alert \(action) did not dismiss the visible alert",
51+
hint: "The alert button was found but the system still reports the alert after tapping it."
52+
)
53+
)
54+
}
3655
return Response(ok: true, data: DataPayload(message: action == "accept" ? "accepted" : "dismissed"))
3756
}
3857

@@ -53,6 +72,21 @@ extension RunnerTests {
5372
return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons)
5473
}
5574

75+
private func alertStillVisible(_ alert: RunnerAlert, actionButtonLabel: String) -> Bool {
76+
guard let current = resolveAlert(app: alert.ownerApp) else {
77+
return false
78+
}
79+
let previousTitle = preferredAlertTitle(alert.root, buttons: alert.buttons)
80+
let currentTitle = preferredAlertTitle(current.root, buttons: current.buttons)
81+
if previousTitle == currentTitle {
82+
return true
83+
}
84+
let normalizedActionLabel = actionButtonLabel.trimmingCharacters(in: .whitespacesAndNewlines)
85+
return current.buttons.contains { button in
86+
button.label.trimmingCharacters(in: .whitespacesAndNewlines) == normalizedActionLabel
87+
}
88+
}
89+
5690
private func firstExistingElement(in elements: [XCUIElement]) -> XCUIElement? {
5791
elements.first { isVisibleElement($0) }
5892
}

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift

Lines changed: 101 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ extension RunnerTests {
1616
.scrollView,
1717
.table
1818
]
19+
private static let flatInteractiveFallbackBudget: TimeInterval = 1.0
1920

2021
private struct SnapshotTraversalContext {
2122
let queryRoot: XCUIElement
@@ -85,6 +86,9 @@ extension RunnerTests {
8586
}
8687

8788
func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload {
89+
if options.interactiveOnly && options.compact {
90+
return snapshotFlatInteractive(app: app, options: options)
91+
}
8892
if let blocking = blockingSystemAlertSnapshot() {
8993
return blocking
9094
}
@@ -257,58 +261,50 @@ extension RunnerTests {
257261
}
258262

259263
private func snapshotFlatInteractive(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
260-
let viewport = safeSnapshotViewport(app: app)
261264
var nodes: [SnapshotNode] = [
262-
SnapshotNode(
263-
index: 0,
264-
type: "Application",
265-
label: nonEmptyElementText { app.label },
266-
identifier: nonEmptyElementText { app.identifier },
267-
value: nil,
268-
rect: snapshotRect(from: safely("SNAPSHOT_FLAT_APP_FRAME", CGRect.zero) { app.frame }),
269-
enabled: true,
270-
focused: nil,
271-
selected: nil,
272-
hittable: false,
273-
depth: 0,
274-
parentIndex: nil,
275-
hiddenContentAbove: nil,
276-
hiddenContentBelow: nil
277-
)
265+
compactInteractiveRootNode(rect: .zero)
278266
]
279267
if options.depth == 0 {
280268
return DataPayload(nodes: nodes, truncated: false)
281269
}
282270

271+
let deadline = options.interactiveOnly
272+
? Date().addingTimeInterval(Self.flatInteractiveFallbackBudget)
273+
: Date.distantFuture
283274
var seen = Set<String>()
284-
let candidates = flatFallbackElements(app: app)
285-
.compactMap { element in
286-
flatSnapshotNode(
287-
element: element,
288-
index: 0,
289-
parentIndex: 0,
290-
viewport: viewport,
291-
options: options
292-
)
275+
var candidates: [SnapshotNode] = []
276+
for element in flatFallbackElements(app: app, options: options) {
277+
if Date() >= deadline {
278+
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_FALLBACK_DEADLINE")
279+
break
293280
}
294-
.filter { node in
295-
let key = "\(node.type)-\(node.label ?? "")-\(node.identifier ?? "")-\(node.value ?? "")-\(node.rect.x)-\(node.rect.y)-\(node.rect.width)-\(node.rect.height)"
296-
if seen.contains(key) { return false }
297-
seen.insert(key)
298-
return true
281+
guard let node = flatSnapshotNode(
282+
element: element,
283+
index: 0,
284+
parentIndex: 0,
285+
viewport: .infinite,
286+
options: options
287+
) else {
288+
continue
299289
}
300-
.sorted { left, right in
301-
if left.rect.y != right.rect.y {
302-
return left.rect.y < right.rect.y
303-
}
304-
if left.rect.x != right.rect.x {
305-
return left.rect.x < right.rect.x
306-
}
307-
return left.type < right.type
290+
let key = "\(node.type)-\(node.label ?? "")-\(node.identifier ?? "")-\(node.value ?? "")-\(node.rect.x)-\(node.rect.y)-\(node.rect.width)-\(node.rect.height)"
291+
if seen.contains(key) { continue }
292+
seen.insert(key)
293+
candidates.append(node)
294+
}
295+
candidates.sort { left, right in
296+
if left.rect.y != right.rect.y {
297+
return left.rect.y < right.rect.y
298+
}
299+
if left.rect.x != right.rect.x {
300+
return left.rect.x < right.rect.x
308301
}
302+
return left.type < right.type
303+
}
309304

310305
let remaining = max(0, fastSnapshotLimit - nodes.count)
311306
let truncated = candidates.count > remaining
307+
nodes[0] = compactInteractiveRootNode(rect: compactInteractiveRootFrame(for: candidates))
312308
for candidate in candidates.prefix(remaining) {
313309
nodes.append(
314310
SnapshotNode(
@@ -332,6 +328,34 @@ extension RunnerTests {
332328
return DataPayload(nodes: nodes, truncated: truncated)
333329
}
334330

331+
private func compactInteractiveRootNode(rect: CGRect) -> SnapshotNode {
332+
SnapshotNode(
333+
index: 0,
334+
type: "Application",
335+
label: nil,
336+
identifier: nil,
337+
value: nil,
338+
rect: snapshotRect(from: rect),
339+
enabled: true,
340+
focused: nil,
341+
selected: nil,
342+
hittable: false,
343+
depth: 0,
344+
parentIndex: nil,
345+
hiddenContentAbove: nil,
346+
hiddenContentBelow: nil
347+
)
348+
}
349+
350+
private func compactInteractiveRootFrame(for candidates: [SnapshotNode]) -> CGRect {
351+
guard !candidates.isEmpty else {
352+
return .zero
353+
}
354+
let maxX = candidates.map { CGFloat($0.rect.x + $0.rect.width) }.max() ?? 0
355+
let maxY = candidates.map { CGFloat($0.rect.y + $0.rect.height) }.max() ?? 0
356+
return CGRect(x: 0, y: 0, width: max(1, maxX), height: max(1, maxY))
357+
}
358+
335359
func snapshotRect(from frame: CGRect) -> SnapshotRect {
336360
return SnapshotRect(
337361
x: Double(frame.origin.x),
@@ -798,8 +822,26 @@ extension RunnerTests {
798822
safely("SNAPSHOT_QUERY", [], fetch)
799823
}
800824

801-
private func flatFallbackElements(app: XCUIApplication) -> [XCUIElement] {
802-
let queries: [XCUIElementQuery] = [
825+
private func flatFallbackElements(app: XCUIApplication, options: SnapshotOptions) -> [XCUIElement] {
826+
let queries: [XCUIElementQuery] = options.interactiveOnly ? [
827+
app.buttons,
828+
app.links,
829+
app.textFields,
830+
app.secureTextFields,
831+
app.searchFields,
832+
app.textViews,
833+
app.switches,
834+
app.sliders,
835+
app.segmentedControls,
836+
app.cells,
837+
app.collectionViews,
838+
app.tables,
839+
app.scrollViews,
840+
app.pickers,
841+
app.steppers,
842+
app.tabBars,
843+
app.menuItems
844+
] : [
803845
app.buttons,
804846
app.cells,
805847
app.collectionViews,
@@ -816,11 +858,26 @@ extension RunnerTests {
816858
app.textFields,
817859
app.textViews
818860
]
819-
return queries.flatMap { query in
820-
safeSnapshotElementsQuery {
821-
query.allElementsBoundByIndex
861+
guard options.interactiveOnly else {
862+
return queries.flatMap { query in
863+
safeSnapshotElementsQuery {
864+
query.allElementsBoundByIndex
865+
}
866+
}
867+
}
868+
869+
let deadline = Date().addingTimeInterval(Self.flatInteractiveFallbackBudget)
870+
var elements: [XCUIElement] = []
871+
for query in queries {
872+
if Date() >= deadline {
873+
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_FALLBACK_DEADLINE")
874+
break
822875
}
876+
elements.append(contentsOf: safeSnapshotElementsQuery {
877+
query.allElementsBoundByIndex
878+
})
823879
}
880+
return elements
824881
}
825882

826883
private func flatSnapshotNode(

src/platforms/ios/__tests__/runner-session.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const {
1717
mockIsProcessAlive,
1818
mockIsProcessGroupAlive,
1919
mockPrepareXctestrunWithEnv,
20+
mockResolveExpectedRunnerCacheMetadata,
21+
mockResolveRunnerDerivedPath,
2022
mockRunAppleToolCommand,
2123
mockRunCmdBackground,
2224
mockRunXcrun,
@@ -31,6 +33,8 @@ const {
3133
mockIsProcessAlive: vi.fn(),
3234
mockIsProcessGroupAlive: vi.fn(),
3335
mockPrepareXctestrunWithEnv: vi.fn(),
36+
mockResolveExpectedRunnerCacheMetadata: vi.fn(),
37+
mockResolveRunnerDerivedPath: vi.fn(),
3438
mockRunAppleToolCommand: vi.fn(),
3539
mockRunCmdBackground: vi.fn(),
3640
mockRunXcrun: vi.fn(),
@@ -88,6 +92,8 @@ vi.mock('../runner-xctestrun.ts', async () => {
8892
acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect,
8993
ensureXctestrunArtifact: mockEnsureXctestrunArtifact,
9094
prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv,
95+
resolveExpectedRunnerCacheMetadata: mockResolveExpectedRunnerCacheMetadata,
96+
resolveRunnerDerivedPath: mockResolveRunnerDerivedPath,
9197
};
9298
});
9399

@@ -116,6 +122,8 @@ beforeEach(() => {
116122
xctestrunPath: '/tmp/session-runner.xctestrun',
117123
jsonPath: '/tmp/session-runner.json',
118124
});
125+
mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 });
126+
mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived');
119127
mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue({ release: mockRedirectRelease });
120128
mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242));
121129
mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
@@ -480,6 +488,36 @@ test('runner session starts xcodebuild through provider seams and reuses an aliv
480488
await stopRunnerSession(session);
481489
});
482490

491+
test('runner session restarts alive runner when expected xctestrun artifact changes', async () => {
492+
const device = { ...IOS_SIMULATOR, id: 'runner-session-stale-artifact-sim' };
493+
494+
mockEnsureXctestrunArtifact
495+
.mockResolvedValueOnce({
496+
xctestrunPath: '/tmp/base-runner.xctestrun',
497+
derived: '/tmp/derived',
498+
cache: 'miss',
499+
artifact: 'rebuilt',
500+
buildMs: 12,
501+
xctestrunPathSource: 'build',
502+
})
503+
.mockResolvedValueOnce({
504+
xctestrunPath: '/tmp/base-runner-next.xctestrun',
505+
derived: '/tmp/derived-next',
506+
cache: 'miss',
507+
artifact: 'rebuilt',
508+
buildMs: 13,
509+
xctestrunPathSource: 'build',
510+
});
511+
512+
const session = await ensureRunnerSession(device, {});
513+
mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived-next');
514+
const restarted = await ensureRunnerSession(device, {});
515+
516+
assert.notEqual(restarted, session);
517+
assert.equal(restarted.xctestrunArtifact?.derived, '/tmp/derived-next');
518+
assert.equal(mockRunCmdBackground.mock.calls.length, 2);
519+
});
520+
483521
test('runner session keeps boot and stale bundle cleanup available when needed', async () => {
484522
const device = { ...IOS_SIMULATOR, id: 'runner-session-clean-sim', booted: false };
485523

0 commit comments

Comments
 (0)