Skip to content

Commit b09e7d3

Browse files
authored
fix: preserve iOS AX snapshot failures (#639)
1 parent 2068f60 commit b09e7d3

5 files changed

Lines changed: 134 additions & 16 deletions

File tree

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -646,12 +646,26 @@ extension RunnerTests {
646646
scope: command.scope,
647647
raw: command.raw ?? false
648648
)
649-
if options.raw {
649+
do {
650+
let payload: DataPayload
651+
if options.raw {
652+
payload = try snapshotRaw(app: activeApp, options: options)
653+
} else {
654+
payload = try snapshotFast(app: activeApp, options: options)
655+
}
650656
needsPostSnapshotInteractionDelay = true
651-
return Response(ok: true, data: snapshotRaw(app: activeApp, options: options))
657+
return Response(ok: true, data: payload)
658+
} catch let failure as SnapshotCaptureFailure {
659+
// Other thrown errors fall through to executeOnMainSafely's generic error response.
660+
return Response(
661+
ok: false,
662+
error: ErrorPayload(
663+
code: failure.code,
664+
message: failure.message,
665+
hint: failure.hint
666+
)
667+
)
652668
}
653-
needsPostSnapshotInteractionDelay = true
654-
return Response(ok: true, data: snapshotFast(app: activeApp, options: options))
655669
case .screenshot:
656670
let screenshot: XCUIScreenshot
657671
#if os(macOS)

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,12 @@ struct DataPayload: Codable {
154154
struct ErrorPayload: Codable {
155155
let code: String?
156156
let message: String
157+
let hint: String?
157158

158-
init(code: String? = nil, message: String) {
159+
init(code: String? = nil, message: String, hint: String? = nil) {
159160
self.code = code
160161
self.message = message
162+
self.hint = hint
161163
}
162164
}
163165

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

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import XCTest
22

33
extension RunnerTests {
4+
private static let axSnapshotErrorCode = "IOS_AX_SNAPSHOT_FAILED"
5+
private static let axSnapshotHint =
6+
"XCTest could not serialize this iOS accessibility tree. Try a smaller read such as snapshot -s <visible label or id> -d 8, use direct selector commands such as find id <value> click, or use screenshot/logs/appstate in the same session. If you own the app and need full-tree inspection, consider flagging this screen for accessibility-tree simplification: reduce unnecessary accessible wrapper nesting and expose stable ids on actionable controls."
47
private static let collapsedTabCandidateTypes: Set<XCUIElement.ElementType> = [
58
.button,
69
.link,
@@ -33,6 +36,12 @@ extension RunnerTests {
3336
let visible: Bool
3437
}
3538

39+
struct SnapshotCaptureFailure: Error {
40+
let code: String
41+
let message: String
42+
let hint: String
43+
}
44+
3645
// MARK: - Snapshot Entry
3746

3847
func elementTypeName(_ type: XCUIElement.ElementType) -> String {
@@ -75,12 +84,12 @@ extension RunnerTests {
7584
}
7685
}
7786

78-
func snapshotFast(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
87+
func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload {
7988
if let blocking = blockingSystemAlertSnapshot() {
8089
return blocking
8190
}
8291

83-
guard let context = makeSnapshotTraversalContext(app: app, options: options) else {
92+
guard let context = try makeSnapshotTraversalContext(app: app, options: options) else {
8493
return DataPayload(nodes: [], truncated: false)
8594
}
8695

@@ -186,12 +195,12 @@ extension RunnerTests {
186195
return DataPayload(nodes: nodes, truncated: truncated)
187196
}
188197

189-
func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
198+
func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload {
190199
if let blocking = blockingSystemAlertSnapshot() {
191200
return blocking
192201
}
193202

194-
guard let context = makeSnapshotTraversalContext(app: app, options: options) else {
203+
guard let context = try makeSnapshotTraversalContext(app: app, options: options) else {
195204
return DataPayload(nodes: [], truncated: false)
196205
}
197206

@@ -304,14 +313,11 @@ extension RunnerTests {
304313
private func makeSnapshotTraversalContext(
305314
app: XCUIApplication,
306315
options: SnapshotOptions
307-
) -> SnapshotTraversalContext? {
308-
let viewport = snapshotViewport(app: app)
316+
) throws -> SnapshotTraversalContext? {
317+
let viewport = safeSnapshotViewport(app: app)
309318
let queryRoot = options.scope.flatMap { findScopeElement(app: app, scope: $0) } ?? app
310319

311-
let rootSnapshot: XCUIElementSnapshot
312-
do {
313-
rootSnapshot = try queryRoot.snapshot()
314-
} catch {
320+
guard let rootSnapshot = try captureSnapshotRoot(queryRoot) else {
315321
return nil
316322
}
317323

@@ -326,6 +332,70 @@ extension RunnerTests {
326332
)
327333
}
328334

335+
private func captureSnapshotRoot(_ element: XCUIElement) throws -> XCUIElementSnapshot? {
336+
var rootSnapshot: XCUIElementSnapshot?
337+
var swiftErrorMessage: String?
338+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
339+
do {
340+
rootSnapshot = try element.snapshot()
341+
} catch {
342+
swiftErrorMessage = describeSnapshotError(error)
343+
}
344+
})
345+
346+
if let rootSnapshot {
347+
return rootSnapshot
348+
}
349+
let message = exceptionMessage ?? swiftErrorMessage ?? "snapshot returned no root"
350+
if Self.isAxIllegalArgument(message) {
351+
throw axSnapshotFailure(message)
352+
}
353+
return nil
354+
}
355+
356+
private func safeSnapshotViewport(app: XCUIApplication) -> CGRect {
357+
var viewport = CGRect.infinite
358+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
359+
viewport = snapshotViewport(app: app)
360+
})
361+
if let exceptionMessage {
362+
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_VIEWPORT_IGNORED_EXCEPTION=%@", exceptionMessage)
363+
}
364+
return viewport
365+
}
366+
367+
private func describeSnapshotError(_ error: Error) -> String {
368+
let localized = error.localizedDescription
369+
let debug = String(describing: error)
370+
if localized.isEmpty { return debug }
371+
if debug == localized { return localized }
372+
return "\(localized) (\(debug))"
373+
}
374+
375+
private func axSnapshotFailure(_ message: String) -> SnapshotCaptureFailure {
376+
let failureMessage: String
377+
if Self.hasAxIllegalArgumentCode(message) {
378+
failureMessage = "iOS XCTest snapshot failed with kAXErrorIllegalArgument. \(message)"
379+
} else {
380+
failureMessage = "iOS XCTest snapshot failed while serializing the accessibility tree. \(message)"
381+
}
382+
return SnapshotCaptureFailure(
383+
code: Self.axSnapshotErrorCode,
384+
message: failureMessage,
385+
hint: Self.axSnapshotHint
386+
)
387+
}
388+
389+
private static func isAxIllegalArgument(_ message: String) -> Bool {
390+
let normalized = message.lowercased()
391+
return hasAxIllegalArgumentCode(normalized)
392+
|| (normalized.contains("illegal argument") && normalized.contains("snapshot"))
393+
}
394+
395+
private static func hasAxIllegalArgumentCode(_ message: String) -> Bool {
396+
return message.lowercased().contains("kaxerrorillegalargument")
397+
}
398+
329399
private func evaluateSnapshot(
330400
_ snapshot: XCUIElementSnapshot,
331401
in context: SnapshotTraversalContext

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,36 @@ test('parseRunnerResponse preserves runner unsupported-operation codes', async (
585585
);
586586
});
587587

588+
test('parseRunnerResponse preserves iOS AX snapshot failure code and hint', async () => {
589+
const hint =
590+
'Try a smaller read such as snapshot -s <visible label or id> -d 8, or use direct selector commands such as find id <value> click.';
591+
const response = new Response(
592+
JSON.stringify({
593+
ok: false,
594+
error: {
595+
code: 'IOS_AX_SNAPSHOT_FAILED',
596+
message: 'iOS XCTest snapshot failed with kAXErrorIllegalArgument.',
597+
hint,
598+
},
599+
}),
600+
);
601+
const session = {
602+
ready: true,
603+
} as any;
604+
605+
await assert.rejects(
606+
() => parseRunnerResponse(response, session, '/tmp/runner.log'),
607+
(error: unknown) => {
608+
assert.ok(error instanceof AppError);
609+
assert.equal(error.code, 'IOS_AX_SNAPSHOT_FAILED');
610+
assert.match(error.message, /kAXErrorIllegalArgument/);
611+
assert.equal(error.details?.hint, hint);
612+
assert.equal(isRetryableRunnerError(error), false);
613+
return true;
614+
},
615+
);
616+
});
617+
588618
test('isRetryableRunnerError does not retry xcodebuild early-exit errors', () => {
589619
const err = new AppError(
590620
'COMMAND_FAILED',

src/platforms/ios/runner-session.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ export async function executeRunnerCommandWithSession(
496496

497497
type RunnerResponsePayload = {
498498
ok?: unknown;
499-
error?: { code?: unknown; message?: unknown };
499+
error?: { code?: unknown; message?: unknown; hint?: unknown };
500500
data?: unknown;
501501
};
502502

@@ -520,13 +520,15 @@ export async function parseRunnerResponse(
520520
? toAppErrorCode(rawCode)
521521
: 'COMMAND_FAILED';
522522
const errorMessage = typeof json.error?.message === 'string' ? json.error.message : undefined;
523+
const hint = typeof json.error?.hint === 'string' ? json.error.hint : undefined;
523524
throw new AppError(errorCode, errorMessage ?? 'Runner error', {
524525
runner: json,
525526
xcodebuild: {
526527
exitCode: 1,
527528
stdout: '',
528529
stderr: '',
529530
},
531+
hint,
530532
logPath,
531533
});
532534
}

0 commit comments

Comments
 (0)