From 8567fed90815689e76b804f65b3eea0c3058f01b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 25 Feb 2026 15:28:22 +0100 Subject: [PATCH] fix: fallback iOS/tvOS screenshot to runner when devicectl screenshot is unavailable --- .../RunnerTests.swift | 19 +++++++++-- src/platforms/ios/__tests__/index.test.ts | 15 +++++++++ src/platforms/ios/apps.ts | 32 ++++++++++++++++--- src/platforms/ios/runner-client.ts | 3 +- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index 4bddd4a55..820f6c844 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -784,6 +784,20 @@ final class RunnerTests: XCTestCase { } needsPostSnapshotInteractionDelay = true return Response(ok: true, data: snapshotFast(app: activeApp, options: options)) + case .screenshot: + guard + let requestedOutPath = command.outPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !requestedOutPath.isEmpty + else { + return Response(ok: false, error: ErrorPayload(message: "screenshot requires outPath")) + } + let screenshot = XCUIScreen.main.screenshot() + do { + try screenshot.pngRepresentation.write(to: URL(fileURLWithPath: requestedOutPath)) + return Response(ok: true, data: DataPayload(message: "screenshot captured")) + } catch { + return Response(ok: false, error: ErrorPayload(message: "failed to write screenshot: \(error.localizedDescription)")) + } case .back: if tapNavigationBack(app: activeApp) { return Response(ok: true, data: DataPayload(message: "back")) @@ -935,7 +949,7 @@ final class RunnerTests: XCTestCase { private func isReadOnlyCommand(_ command: Command) -> Bool { switch command.command { - case .findText, .snapshot: + case .findText, .snapshot, .screenshot: return true case .alert: let action = (command.action ?? "get").lowercased() @@ -962,7 +976,7 @@ final class RunnerTests: XCTestCase { private func isRunnerLifecycleCommand(_ command: CommandType) -> Bool { switch command { - case .shutdown, .recordStop: + case .shutdown, .recordStop, .screenshot: return true default: return false @@ -1818,6 +1832,7 @@ enum CommandType: String, Codable { case swipe case findText case snapshot + case screenshot case back case home case appSwitcher diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index aa650fc70..0b18d28de 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -14,6 +14,7 @@ import { setIosSetting, writeIosClipboardText, } from '../index.ts'; +import { shouldFallbackToRunnerForIosScreenshot } from '../apps.ts'; import type { DeviceInfo } from '../../../utils/device.ts'; import { AppError } from '../../../utils/errors.ts'; @@ -114,6 +115,20 @@ test('openIosApp custom scheme deep links on iOS devices require app bundle cont ); }); +test('shouldFallbackToRunnerForIosScreenshot detects removed devicectl subcommand output', () => { + const error = new AppError('COMMAND_FAILED', 'Failed to capture iOS screenshot', { + stderr: "error: Unknown option '--device'", + }); + assert.equal(shouldFallbackToRunnerForIosScreenshot(error), true); +}); + +test('shouldFallbackToRunnerForIosScreenshot ignores unrelated command failures', () => { + const error = new AppError('COMMAND_FAILED', 'Failed to capture iOS screenshot', { + stderr: 'error: device is busy connecting', + }); + assert.equal(shouldFallbackToRunnerForIosScreenshot(error), false); +}); + test('openIosApp web URL on iOS device without app falls back to Safari', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-safari-test-')); const xcrunPath = path.join(tmpDir, 'xcrun'); diff --git a/src/platforms/ios/apps.ts b/src/platforms/ios/apps.ts index 05b84059b..71a95cbc8 100644 --- a/src/platforms/ios/apps.ts +++ b/src/platforms/ios/apps.ts @@ -12,6 +12,7 @@ import { type PermissionSettingOptions, } from '../permission-utils.ts'; import { parseAppearanceAction } from '../appearance.ts'; +import { runIosRunnerCommand } from './runner-client.ts'; import { IOS_APP_LAUNCH_TIMEOUT_MS, IOS_DEVICECTL_TIMEOUT_MS } from './config.ts'; import { @@ -221,10 +222,33 @@ export async function screenshotIos(device: DeviceInfo, outPath: string): Promis return; } - await runIosDevicectl(['device', 'screenshot', '--device', device.id, outPath], { - action: 'capture iOS screenshot', - deviceId: device.id, - }); + try { + await runIosDevicectl(['device', 'screenshot', '--device', device.id, outPath], { + action: 'capture iOS screenshot', + deviceId: device.id, + }); + return; + } catch (error) { + if (!shouldFallbackToRunnerForIosScreenshot(error)) { + throw error; + } + } + + await runIosRunnerCommand(device, { command: 'screenshot', outPath }); +} + +export function shouldFallbackToRunnerForIosScreenshot(error: unknown): boolean { + if (!(error instanceof AppError)) return false; + if (error.code !== 'COMMAND_FAILED') return false; + const details = (error.details ?? {}) as { stdout?: unknown; stderr?: unknown }; + const stdout = typeof details.stdout === 'string' ? details.stdout : ''; + const stderr = typeof details.stderr === 'string' ? details.stderr : ''; + const combined = `${error.message}\n${stdout}\n${stderr}`.toLowerCase(); + return ( + combined.includes("unknown option '--device'") || + (combined.includes('unknown subcommand') && combined.includes('screenshot')) || + (combined.includes('unrecognized subcommand') && combined.includes('screenshot')) + ); } export async function readIosClipboardText(device: DeviceInfo): Promise { diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index c0d5a77fe..024474e42 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -24,6 +24,7 @@ type RunnerCommand = { | 'swipe' | 'findText' | 'snapshot' + | 'screenshot' | 'back' | 'home' | 'appSwitcher' @@ -654,7 +655,7 @@ export function isRetryableRunnerError(err: unknown): boolean { } function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean { - return command === 'snapshot' || command === 'findText' || command === 'alert'; + return command === 'snapshot' || command === 'screenshot' || command === 'findText' || command === 'alert'; } function assertRunnerRequestActive(requestId: string | undefined): void {