Skip to content

Commit 43aae1d

Browse files
nickdimathymikee
andauthored
fix(ios): correctly implement runner fallback for physical device screenshots (#132)
* fix(ios): use XCTest runner for screenshots on physical devices `xcrun devicectl device screenshot` was removed in Xcode 26.x, causing `agent-device screenshot` to fail on physical iOS devices with exit code 64. This fix routes physical device screenshots through the AgentDeviceRunner XCTest runner (already used for snapshot/interaction commands) instead: - Add `screenshot` command to the Swift runner's `CommandType` enum - Implement the handler using `XCUIScreen.main.screenshot()`, writing a timestamped PNG to the app's temp directory (returned as `tmp/<file>`) - If `appBundleId` is provided, activate the target app before capturing so the screenshot shows the app under test rather than the runner itself - In `screenshotIos()`, pass `appBundleId` to the runner command then pull the file back to the host via `xcrun devicectl device copy from` (same mechanism used by the recording feature) - Thread `appBundleId` through `Interactor.screenshot()` → `dispatch.ts` → `screenshotIos()` so the active session's app is always captured - Extract `IOS_RUNNER_CONTAINER_BUNDLE_IDS` to `runner-client.ts` so it can be shared between `apps.ts` and `record-trace.ts` Fixes: screenshots failing with "Failed to capture iOS screenshot" on devices using Xcode 26.x / devicectl 506.6+ * fix: clean up iOS screenshot fallback follow-ups --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
1 parent 8123117 commit 43aae1d

6 files changed

Lines changed: 74 additions & 34 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -785,19 +785,27 @@ final class RunnerTests: XCTestCase {
785785
needsPostSnapshotInteractionDelay = true
786786
return Response(ok: true, data: snapshotFast(app: activeApp, options: options))
787787
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"))
788+
// If a target app bundle ID is provided, activate it first so the screenshot
789+
// captures the target app rather than the AgentDeviceRunner itself.
790+
if let bundleId = command.appBundleId, !bundleId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
791+
let targetApp = XCUIApplication(bundleIdentifier: bundleId)
792+
targetApp.activate()
793+
// Brief wait for the app transition animation to complete
794+
Thread.sleep(forTimeInterval: 0.5)
793795
}
794796
let screenshot = XCUIScreen.main.screenshot()
797+
guard let pngData = screenshot.image.pngData() else {
798+
return Response(ok: false, error: ErrorPayload(message: "Failed to encode screenshot as PNG"))
799+
}
800+
let fileName = "screenshot-\(Int(Date().timeIntervalSince1970 * 1000)).png"
801+
let filePath = (NSTemporaryDirectory() as NSString).appendingPathComponent(fileName)
795802
do {
796-
try screenshot.pngRepresentation.write(to: URL(fileURLWithPath: requestedOutPath))
797-
return Response(ok: true, data: DataPayload(message: "screenshot captured"))
803+
try pngData.write(to: URL(fileURLWithPath: filePath))
798804
} catch {
799-
return Response(ok: false, error: ErrorPayload(message: "failed to write screenshot: \(error.localizedDescription)"))
805+
return Response(ok: false, error: ErrorPayload(message: "Failed to write screenshot: \(error.localizedDescription)"))
800806
}
807+
// Return path relative to app container root (tmp/ maps to NSTemporaryDirectory)
808+
return Response(ok: true, data: DataPayload(message: "tmp/\(fileName)"))
801809
case .back:
802810
if tapNavigationBack(app: activeApp) {
803811
return Response(ok: true, data: DataPayload(message: "back"))

src/core/dispatch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ export async function dispatchCommand(
375375
const positionalPath = positionals[0];
376376
const screenshotPath = positionalPath ?? outPath ?? `./screenshot-${Date.now()}.png`;
377377
await fs.mkdir(pathModule.dirname(screenshotPath), { recursive: true });
378-
await interactor.screenshot(screenshotPath);
378+
await interactor.screenshot(screenshotPath, context?.appBundleId);
379379
return { path: screenshotPath };
380380
}
381381
case 'back': {

src/daemon/handlers/record-trace.ts

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,13 @@ import path from 'node:path';
33
import { runCmd, runCmdBackground } from '../../utils/exec.ts';
44
import { resolveTargetDevice, type CommandFlags } from '../../core/dispatch.ts';
55
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
6-
import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts';
6+
import { runIosRunnerCommand, IOS_RUNNER_CONTAINER_BUNDLE_IDS } from '../../platforms/ios/runner-client.ts';
77
import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
88
import { SessionStore } from '../session-store.ts';
99
import { ensureDeviceReady } from '../device-ready.ts';
1010
import { emitDiagnostic } from '../../utils/diagnostics.ts';
1111

12-
function uniqueNonEmpty(values: Array<string | undefined>): string[] {
13-
const seen = new Set<string>();
14-
const out: string[] = [];
15-
for (const value of values) {
16-
if (!value) continue;
17-
const trimmed = value.trim();
18-
if (!trimmed || seen.has(trimmed)) continue;
19-
seen.add(trimmed);
20-
out.push(trimmed);
21-
}
22-
return out;
23-
}
2412

25-
const IOS_RUNNER_CONTAINER_BUNDLE_IDS = uniqueNonEmpty([
26-
process.env.AGENT_DEVICE_IOS_RUNNER_CONTAINER_BUNDLE_ID,
27-
process.env.AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID,
28-
'com.myapp.AgentDeviceRunnerUITests.xctrunner',
29-
'com.myapp.AgentDeviceRunner',
30-
]);
3113
const IOS_DEVICE_RECORD_MIN_FPS = 1;
3214
const IOS_DEVICE_RECORD_MAX_FPS = 120;
3315

src/platforms/ios/apps.ts

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

1716
import { IOS_APP_LAUNCH_TIMEOUT_MS, IOS_DEVICECTL_TIMEOUT_MS } from './config.ts';
1817
import {
@@ -22,6 +21,7 @@ import {
2221
runIosDevicectl,
2322
type IosAppInfo,
2423
} from './devicectl.ts';
24+
import { runIosRunnerCommand, IOS_RUNNER_CONTAINER_BUNDLE_IDS } from './runner-client.ts';
2525
import { ensureBootedSimulator, ensureSimulator, getSimulatorState } from './simulator.ts';
2626

2727
const ALIASES: Record<string, string> = {
@@ -215,7 +215,7 @@ export async function reinstallIosApp(
215215
return { bundleId };
216216
}
217217

218-
export async function screenshotIos(device: DeviceInfo, outPath: string): Promise<void> {
218+
export async function screenshotIos(device: DeviceInfo, outPath: string, appBundleId?: string): Promise<void> {
219219
if (device.kind === 'simulator') {
220220
await ensureBootedSimulator(device);
221221
await runCmd('xcrun', ['simctl', 'io', device.id, 'screenshot', outPath]);
@@ -234,7 +234,46 @@ export async function screenshotIos(device: DeviceInfo, outPath: string): Promis
234234
}
235235
}
236236

237-
await runIosRunnerCommand(device, { command: 'screenshot', outPath });
237+
// `xcrun devicectl device screenshot` is unavailable (removed in Xcode 26.x).
238+
// Fall back to the XCTest runner: capture to the device's temp directory,
239+
// then pull the file to the host via `devicectl device copy from`.
240+
const result = await runIosRunnerCommand(device, { command: 'screenshot', appBundleId });
241+
const remoteFileName = result['message'] as string;
242+
if (!remoteFileName) {
243+
throw new AppError('COMMAND_FAILED', 'Failed to capture iOS screenshot: runner returned no file path');
244+
}
245+
246+
let copyResult = { exitCode: 1, stdout: '', stderr: '' };
247+
for (const bundleId of IOS_RUNNER_CONTAINER_BUNDLE_IDS) {
248+
copyResult = await runCmd(
249+
'xcrun',
250+
[
251+
'devicectl',
252+
'device',
253+
'copy',
254+
'from',
255+
'--device',
256+
device.id,
257+
'--source',
258+
remoteFileName,
259+
'--destination',
260+
outPath,
261+
'--domain-type',
262+
'appDataContainer',
263+
'--domain-identifier',
264+
bundleId,
265+
],
266+
{ allowFailure: true },
267+
);
268+
if (copyResult.exitCode === 0) {
269+
break;
270+
}
271+
}
272+
273+
if (copyResult.exitCode !== 0) {
274+
const copyError = copyResult.stderr.trim() || copyResult.stdout.trim() || `devicectl exited with code ${copyResult.exitCode}`;
275+
throw new AppError('COMMAND_FAILED', `Failed to capture iOS screenshot: ${copyError}`);
276+
}
238277
}
239278

240279
export function shouldFallbackToRunnerForIosScreenshot(error: unknown): boolean {

src/platforms/ios/runner-client.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
1313
import { resolveTimeoutMs, resolveTimeoutSeconds } from '../../utils/timeouts.ts';
1414
import { isRequestCanceled } from '../../daemon/request-cancel.ts';
1515

16+
const iosRunnerContainerBundleIds = [
17+
process.env.AGENT_DEVICE_IOS_RUNNER_CONTAINER_BUNDLE_ID,
18+
process.env.AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID,
19+
'com.myapp.AgentDeviceRunnerUITests.xctrunner',
20+
'com.myapp.AgentDeviceRunner',
21+
]
22+
.map((id) => id?.trim() ?? '')
23+
.filter((id) => id.length > 0);
24+
25+
export const IOS_RUNNER_CONTAINER_BUNDLE_IDS: string[] = Array.from(new Set(iosRunnerContainerBundleIds));
26+
1627
type RunnerCommand = {
1728
command:
1829
| 'tap'

src/utils/interactors.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type Interactor = {
4444
fill(x: number, y: number, text: string): Promise<void>;
4545
scroll(direction: string, amount?: number): Promise<void>;
4646
scrollIntoView(text: string): Promise<{ attempts?: number } | void>;
47-
screenshot(outPath: string): Promise<void>;
47+
screenshot(outPath: string, appBundleId?: string): Promise<void>;
4848
};
4949

5050
export function getInteractor(device: DeviceInfo, runnerContext: RunnerContext): Interactor {
@@ -66,14 +66,14 @@ export function getInteractor(device: DeviceInfo, runnerContext: RunnerContext):
6666
fill: (x, y, text) => fillAndroid(device, x, y, text),
6767
scroll: (direction, amount) => scrollAndroid(device, direction, amount),
6868
scrollIntoView: (text) => scrollIntoViewAndroid(device, text),
69-
screenshot: (outPath) => screenshotAndroid(device, outPath),
69+
screenshot: (outPath, _appBundleId) => screenshotAndroid(device, outPath),
7070
};
7171
case 'ios':
7272
return {
7373
open: (app, options) => openIosApp(device, app, { appBundleId: options?.appBundleId, url: options?.url }),
7474
openDevice: () => openIosDevice(device),
7575
close: (app) => closeIosApp(device, app),
76-
screenshot: (outPath) => screenshotIos(device, outPath),
76+
screenshot: (outPath, appBundleId) => screenshotIos(device, outPath, appBundleId),
7777
...iosRunnerOverrides(device, runnerContext),
7878
};
7979
default:

0 commit comments

Comments
 (0)