Skip to content

Commit 8123117

Browse files
authored
fix: fallback iOS/tvOS screenshot to runner when devicectl screenshot is unavailable (#130)
1 parent ef20dea commit 8123117

4 files changed

Lines changed: 62 additions & 7 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,20 @@ final class RunnerTests: XCTestCase {
784784
}
785785
needsPostSnapshotInteractionDelay = true
786786
return Response(ok: true, data: snapshotFast(app: activeApp, options: options))
787+
case .screenshot:
788+
guard
789+
let requestedOutPath = command.outPath?.trimmingCharacters(in: .whitespacesAndNewlines),
790+
!requestedOutPath.isEmpty
791+
else {
792+
return Response(ok: false, error: ErrorPayload(message: "screenshot requires outPath"))
793+
}
794+
let screenshot = XCUIScreen.main.screenshot()
795+
do {
796+
try screenshot.pngRepresentation.write(to: URL(fileURLWithPath: requestedOutPath))
797+
return Response(ok: true, data: DataPayload(message: "screenshot captured"))
798+
} catch {
799+
return Response(ok: false, error: ErrorPayload(message: "failed to write screenshot: \(error.localizedDescription)"))
800+
}
787801
case .back:
788802
if tapNavigationBack(app: activeApp) {
789803
return Response(ok: true, data: DataPayload(message: "back"))
@@ -935,7 +949,7 @@ final class RunnerTests: XCTestCase {
935949

936950
private func isReadOnlyCommand(_ command: Command) -> Bool {
937951
switch command.command {
938-
case .findText, .snapshot:
952+
case .findText, .snapshot, .screenshot:
939953
return true
940954
case .alert:
941955
let action = (command.action ?? "get").lowercased()
@@ -962,7 +976,7 @@ final class RunnerTests: XCTestCase {
962976

963977
private func isRunnerLifecycleCommand(_ command: CommandType) -> Bool {
964978
switch command {
965-
case .shutdown, .recordStop:
979+
case .shutdown, .recordStop, .screenshot:
966980
return true
967981
default:
968982
return false
@@ -1818,6 +1832,7 @@ enum CommandType: String, Codable {
18181832
case swipe
18191833
case findText
18201834
case snapshot
1835+
case screenshot
18211836
case back
18221837
case home
18231838
case appSwitcher

src/platforms/ios/__tests__/index.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
setIosSetting,
1515
writeIosClipboardText,
1616
} from '../index.ts';
17+
import { shouldFallbackToRunnerForIosScreenshot } from '../apps.ts';
1718
import type { DeviceInfo } from '../../../utils/device.ts';
1819
import { AppError } from '../../../utils/errors.ts';
1920

@@ -114,6 +115,20 @@ test('openIosApp custom scheme deep links on iOS devices require app bundle cont
114115
);
115116
});
116117

118+
test('shouldFallbackToRunnerForIosScreenshot detects removed devicectl subcommand output', () => {
119+
const error = new AppError('COMMAND_FAILED', 'Failed to capture iOS screenshot', {
120+
stderr: "error: Unknown option '--device'",
121+
});
122+
assert.equal(shouldFallbackToRunnerForIosScreenshot(error), true);
123+
});
124+
125+
test('shouldFallbackToRunnerForIosScreenshot ignores unrelated command failures', () => {
126+
const error = new AppError('COMMAND_FAILED', 'Failed to capture iOS screenshot', {
127+
stderr: 'error: device is busy connecting',
128+
});
129+
assert.equal(shouldFallbackToRunnerForIosScreenshot(error), false);
130+
});
131+
117132
test('openIosApp web URL on iOS device without app falls back to Safari', async () => {
118133
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-safari-test-'));
119134
const xcrunPath = path.join(tmpDir, 'xcrun');

src/platforms/ios/apps.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type PermissionSettingOptions,
1313
} from '../permission-utils.ts';
1414
import { parseAppearanceAction } from '../appearance.ts';
15+
import { runIosRunnerCommand } from './runner-client.ts';
1516

1617
import { IOS_APP_LAUNCH_TIMEOUT_MS, IOS_DEVICECTL_TIMEOUT_MS } from './config.ts';
1718
import {
@@ -221,10 +222,33 @@ export async function screenshotIos(device: DeviceInfo, outPath: string): Promis
221222
return;
222223
}
223224

224-
await runIosDevicectl(['device', 'screenshot', '--device', device.id, outPath], {
225-
action: 'capture iOS screenshot',
226-
deviceId: device.id,
227-
});
225+
try {
226+
await runIosDevicectl(['device', 'screenshot', '--device', device.id, outPath], {
227+
action: 'capture iOS screenshot',
228+
deviceId: device.id,
229+
});
230+
return;
231+
} catch (error) {
232+
if (!shouldFallbackToRunnerForIosScreenshot(error)) {
233+
throw error;
234+
}
235+
}
236+
237+
await runIosRunnerCommand(device, { command: 'screenshot', outPath });
238+
}
239+
240+
export function shouldFallbackToRunnerForIosScreenshot(error: unknown): boolean {
241+
if (!(error instanceof AppError)) return false;
242+
if (error.code !== 'COMMAND_FAILED') return false;
243+
const details = (error.details ?? {}) as { stdout?: unknown; stderr?: unknown };
244+
const stdout = typeof details.stdout === 'string' ? details.stdout : '';
245+
const stderr = typeof details.stderr === 'string' ? details.stderr : '';
246+
const combined = `${error.message}\n${stdout}\n${stderr}`.toLowerCase();
247+
return (
248+
combined.includes("unknown option '--device'") ||
249+
(combined.includes('unknown subcommand') && combined.includes('screenshot')) ||
250+
(combined.includes('unrecognized subcommand') && combined.includes('screenshot'))
251+
);
228252
}
229253

230254
export async function readIosClipboardText(device: DeviceInfo): Promise<string> {

src/platforms/ios/runner-client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type RunnerCommand = {
2424
| 'swipe'
2525
| 'findText'
2626
| 'snapshot'
27+
| 'screenshot'
2728
| 'back'
2829
| 'home'
2930
| 'appSwitcher'
@@ -654,7 +655,7 @@ export function isRetryableRunnerError(err: unknown): boolean {
654655
}
655656

656657
function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean {
657-
return command === 'snapshot' || command === 'findText' || command === 'alert';
658+
return command === 'snapshot' || command === 'screenshot' || command === 'findText' || command === 'alert';
658659
}
659660

660661
function assertRunnerRequestActive(requestId: string | undefined): void {

0 commit comments

Comments
 (0)