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
Original file line number Diff line number Diff line change
Expand Up @@ -646,12 +646,26 @@ extension RunnerTests {
scope: command.scope,
raw: command.raw ?? false
)
if options.raw {
do {
let payload: DataPayload
if options.raw {
payload = try snapshotRaw(app: activeApp, options: options)
} else {
payload = try snapshotFast(app: activeApp, options: options)
}
needsPostSnapshotInteractionDelay = true
return Response(ok: true, data: snapshotRaw(app: activeApp, options: options))
return Response(ok: true, data: payload)
} catch let failure as SnapshotCaptureFailure {
// Other thrown errors fall through to executeOnMainSafely's generic error response.
return Response(
ok: false,
error: ErrorPayload(
code: failure.code,
message: failure.message,
hint: failure.hint
)
)
}
needsPostSnapshotInteractionDelay = true
return Response(ok: true, data: snapshotFast(app: activeApp, options: options))
case .screenshot:
let screenshot: XCUIScreenshot
#if os(macOS)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,12 @@ struct DataPayload: Codable {
struct ErrorPayload: Codable {
let code: String?
let message: String
let hint: String?

init(code: String? = nil, message: String) {
init(code: String? = nil, message: String, hint: String? = nil) {
self.code = code
self.message = message
self.hint = hint
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import XCTest

extension RunnerTests {
private static let axSnapshotErrorCode = "IOS_AX_SNAPSHOT_FAILED"
private static let axSnapshotHint =
"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."
private static let collapsedTabCandidateTypes: Set<XCUIElement.ElementType> = [
.button,
.link,
Expand Down Expand Up @@ -33,6 +36,12 @@ extension RunnerTests {
let visible: Bool
}

struct SnapshotCaptureFailure: Error {
let code: String
let message: String
let hint: String
}

// MARK: - Snapshot Entry

func elementTypeName(_ type: XCUIElement.ElementType) -> String {
Expand Down Expand Up @@ -75,12 +84,12 @@ extension RunnerTests {
}
}

func snapshotFast(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload {
if let blocking = blockingSystemAlertSnapshot() {
return blocking
}

guard let context = makeSnapshotTraversalContext(app: app, options: options) else {
guard let context = try makeSnapshotTraversalContext(app: app, options: options) else {
return DataPayload(nodes: [], truncated: false)
}

Expand Down Expand Up @@ -186,12 +195,12 @@ extension RunnerTests {
return DataPayload(nodes: nodes, truncated: truncated)
}

func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload {
if let blocking = blockingSystemAlertSnapshot() {
return blocking
}

guard let context = makeSnapshotTraversalContext(app: app, options: options) else {
guard let context = try makeSnapshotTraversalContext(app: app, options: options) else {
return DataPayload(nodes: [], truncated: false)
}

Expand Down Expand Up @@ -304,14 +313,11 @@ extension RunnerTests {
private func makeSnapshotTraversalContext(
app: XCUIApplication,
options: SnapshotOptions
) -> SnapshotTraversalContext? {
let viewport = snapshotViewport(app: app)
) throws -> SnapshotTraversalContext? {
let viewport = safeSnapshotViewport(app: app)
let queryRoot = options.scope.flatMap { findScopeElement(app: app, scope: $0) } ?? app

let rootSnapshot: XCUIElementSnapshot
do {
rootSnapshot = try queryRoot.snapshot()
} catch {
guard let rootSnapshot = try captureSnapshotRoot(queryRoot) else {
return nil
}

Expand All @@ -326,6 +332,70 @@ extension RunnerTests {
)
}

private func captureSnapshotRoot(_ element: XCUIElement) throws -> XCUIElementSnapshot? {
var rootSnapshot: XCUIElementSnapshot?
var swiftErrorMessage: String?
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
do {
rootSnapshot = try element.snapshot()
} catch {
swiftErrorMessage = describeSnapshotError(error)
}
})

if let rootSnapshot {
return rootSnapshot
}
let message = exceptionMessage ?? swiftErrorMessage ?? "snapshot returned no root"
if Self.isAxIllegalArgument(message) {
throw axSnapshotFailure(message)
}
return nil
}

private func safeSnapshotViewport(app: XCUIApplication) -> CGRect {
var viewport = CGRect.infinite
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
viewport = snapshotViewport(app: app)
})
if let exceptionMessage {
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_VIEWPORT_IGNORED_EXCEPTION=%@", exceptionMessage)
}
return viewport
}

private func describeSnapshotError(_ error: Error) -> String {
let localized = error.localizedDescription
let debug = String(describing: error)
if localized.isEmpty { return debug }
if debug == localized { return localized }
return "\(localized) (\(debug))"
}

private func axSnapshotFailure(_ message: String) -> SnapshotCaptureFailure {
let failureMessage: String
if Self.hasAxIllegalArgumentCode(message) {
failureMessage = "iOS XCTest snapshot failed with kAXErrorIllegalArgument. \(message)"
} else {
failureMessage = "iOS XCTest snapshot failed while serializing the accessibility tree. \(message)"
}
return SnapshotCaptureFailure(
code: Self.axSnapshotErrorCode,
message: failureMessage,
hint: Self.axSnapshotHint
)
}

private static func isAxIllegalArgument(_ message: String) -> Bool {
let normalized = message.lowercased()
return hasAxIllegalArgumentCode(normalized)
|| (normalized.contains("illegal argument") && normalized.contains("snapshot"))
}

private static func hasAxIllegalArgumentCode(_ message: String) -> Bool {
return message.lowercased().contains("kaxerrorillegalargument")
}

private func evaluateSnapshot(
_ snapshot: XCUIElementSnapshot,
in context: SnapshotTraversalContext
Expand Down
30 changes: 30 additions & 0 deletions src/platforms/ios/__tests__/runner-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,36 @@ test('parseRunnerResponse preserves runner unsupported-operation codes', async (
);
});

test('parseRunnerResponse preserves iOS AX snapshot failure code and hint', async () => {
const hint =
'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.';
const response = new Response(
JSON.stringify({
ok: false,
error: {
code: 'IOS_AX_SNAPSHOT_FAILED',
message: 'iOS XCTest snapshot failed with kAXErrorIllegalArgument.',
hint,
},
}),
);
const session = {
ready: true,
} as any;

await assert.rejects(
() => parseRunnerResponse(response, session, '/tmp/runner.log'),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'IOS_AX_SNAPSHOT_FAILED');
assert.match(error.message, /kAXErrorIllegalArgument/);
assert.equal(error.details?.hint, hint);
assert.equal(isRetryableRunnerError(error), false);
return true;
},
);
});

test('isRetryableRunnerError does not retry xcodebuild early-exit errors', () => {
const err = new AppError(
'COMMAND_FAILED',
Expand Down
4 changes: 3 additions & 1 deletion src/platforms/ios/runner-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ export async function executeRunnerCommandWithSession(

type RunnerResponsePayload = {
ok?: unknown;
error?: { code?: unknown; message?: unknown };
error?: { code?: unknown; message?: unknown; hint?: unknown };
data?: unknown;
};

Expand All @@ -520,13 +520,15 @@ export async function parseRunnerResponse(
? toAppErrorCode(rawCode)
: 'COMMAND_FAILED';
const errorMessage = typeof json.error?.message === 'string' ? json.error.message : undefined;
const hint = typeof json.error?.hint === 'string' ? json.error.hint : undefined;
throw new AppError(errorCode, errorMessage ?? 'Runner error', {
runner: json,
xcodebuild: {
exitCode: 1,
stdout: '',
stderr: '',
},
hint,
logPath,
});
}
Expand Down
Loading