diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index 820f6c844..5573001ce 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -785,19 +785,27 @@ 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")) + // If a target app bundle ID is provided, activate it first so the screenshot + // captures the target app rather than the AgentDeviceRunner itself. + if let bundleId = command.appBundleId, !bundleId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let targetApp = XCUIApplication(bundleIdentifier: bundleId) + targetApp.activate() + // Brief wait for the app transition animation to complete + Thread.sleep(forTimeInterval: 0.5) } let screenshot = XCUIScreen.main.screenshot() + guard let pngData = screenshot.image.pngData() else { + return Response(ok: false, error: ErrorPayload(message: "Failed to encode screenshot as PNG")) + } + let fileName = "screenshot-\(Int(Date().timeIntervalSince1970 * 1000)).png" + let filePath = (NSTemporaryDirectory() as NSString).appendingPathComponent(fileName) do { - try screenshot.pngRepresentation.write(to: URL(fileURLWithPath: requestedOutPath)) - return Response(ok: true, data: DataPayload(message: "screenshot captured")) + try pngData.write(to: URL(fileURLWithPath: filePath)) } catch { - return Response(ok: false, error: ErrorPayload(message: "failed to write screenshot: \(error.localizedDescription)")) + return Response(ok: false, error: ErrorPayload(message: "Failed to write screenshot: \(error.localizedDescription)")) } + // Return path relative to app container root (tmp/ maps to NSTemporaryDirectory) + return Response(ok: true, data: DataPayload(message: "tmp/\(fileName)")) case .back: if tapNavigationBack(app: activeApp) { return Response(ok: true, data: DataPayload(message: "back")) diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 5b373cc9f..02939b967 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -375,7 +375,7 @@ export async function dispatchCommand( const positionalPath = positionals[0]; const screenshotPath = positionalPath ?? outPath ?? `./screenshot-${Date.now()}.png`; await fs.mkdir(pathModule.dirname(screenshotPath), { recursive: true }); - await interactor.screenshot(screenshotPath); + await interactor.screenshot(screenshotPath, context?.appBundleId); return { path: screenshotPath }; } case 'back': { diff --git a/src/daemon/handlers/record-trace.ts b/src/daemon/handlers/record-trace.ts index b9a8107ae..81d3b5c4e 100644 --- a/src/daemon/handlers/record-trace.ts +++ b/src/daemon/handlers/record-trace.ts @@ -3,31 +3,13 @@ import path from 'node:path'; import { runCmd, runCmdBackground } from '../../utils/exec.ts'; import { resolveTargetDevice, type CommandFlags } from '../../core/dispatch.ts'; import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; -import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts'; +import { runIosRunnerCommand, IOS_RUNNER_CONTAINER_BUNDLE_IDS } from '../../platforms/ios/runner-client.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { ensureDeviceReady } from '../device-ready.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; -function uniqueNonEmpty(values: Array): string[] { - const seen = new Set(); - const out: string[] = []; - for (const value of values) { - if (!value) continue; - const trimmed = value.trim(); - if (!trimmed || seen.has(trimmed)) continue; - seen.add(trimmed); - out.push(trimmed); - } - return out; -} -const IOS_RUNNER_CONTAINER_BUNDLE_IDS = uniqueNonEmpty([ - process.env.AGENT_DEVICE_IOS_RUNNER_CONTAINER_BUNDLE_ID, - process.env.AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID, - 'com.myapp.AgentDeviceRunnerUITests.xctrunner', - 'com.myapp.AgentDeviceRunner', -]); const IOS_DEVICE_RECORD_MIN_FPS = 1; const IOS_DEVICE_RECORD_MAX_FPS = 120; diff --git a/src/platforms/ios/apps.ts b/src/platforms/ios/apps.ts index 71a95cbc8..aea2b9f74 100644 --- a/src/platforms/ios/apps.ts +++ b/src/platforms/ios/apps.ts @@ -12,7 +12,6 @@ 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 { @@ -22,6 +21,7 @@ import { runIosDevicectl, type IosAppInfo, } from './devicectl.ts'; +import { runIosRunnerCommand, IOS_RUNNER_CONTAINER_BUNDLE_IDS } from './runner-client.ts'; import { ensureBootedSimulator, ensureSimulator, getSimulatorState } from './simulator.ts'; const ALIASES: Record = { @@ -215,7 +215,7 @@ export async function reinstallIosApp( return { bundleId }; } -export async function screenshotIos(device: DeviceInfo, outPath: string): Promise { +export async function screenshotIos(device: DeviceInfo, outPath: string, appBundleId?: string): Promise { if (device.kind === 'simulator') { await ensureBootedSimulator(device); await runCmd('xcrun', ['simctl', 'io', device.id, 'screenshot', outPath]); @@ -234,7 +234,46 @@ export async function screenshotIos(device: DeviceInfo, outPath: string): Promis } } - await runIosRunnerCommand(device, { command: 'screenshot', outPath }); + // `xcrun devicectl device screenshot` is unavailable (removed in Xcode 26.x). + // Fall back to the XCTest runner: capture to the device's temp directory, + // then pull the file to the host via `devicectl device copy from`. + const result = await runIosRunnerCommand(device, { command: 'screenshot', appBundleId }); + const remoteFileName = result['message'] as string; + if (!remoteFileName) { + throw new AppError('COMMAND_FAILED', 'Failed to capture iOS screenshot: runner returned no file path'); + } + + let copyResult = { exitCode: 1, stdout: '', stderr: '' }; + for (const bundleId of IOS_RUNNER_CONTAINER_BUNDLE_IDS) { + copyResult = await runCmd( + 'xcrun', + [ + 'devicectl', + 'device', + 'copy', + 'from', + '--device', + device.id, + '--source', + remoteFileName, + '--destination', + outPath, + '--domain-type', + 'appDataContainer', + '--domain-identifier', + bundleId, + ], + { allowFailure: true }, + ); + if (copyResult.exitCode === 0) { + break; + } + } + + if (copyResult.exitCode !== 0) { + const copyError = copyResult.stderr.trim() || copyResult.stdout.trim() || `devicectl exited with code ${copyResult.exitCode}`; + throw new AppError('COMMAND_FAILED', `Failed to capture iOS screenshot: ${copyError}`); + } } export function shouldFallbackToRunnerForIosScreenshot(error: unknown): boolean { diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index 024474e42..9638dde67 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -13,6 +13,17 @@ import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts'; import { resolveTimeoutMs, resolveTimeoutSeconds } from '../../utils/timeouts.ts'; import { isRequestCanceled } from '../../daemon/request-cancel.ts'; +const iosRunnerContainerBundleIds = [ + process.env.AGENT_DEVICE_IOS_RUNNER_CONTAINER_BUNDLE_ID, + process.env.AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID, + 'com.myapp.AgentDeviceRunnerUITests.xctrunner', + 'com.myapp.AgentDeviceRunner', +] + .map((id) => id?.trim() ?? '') + .filter((id) => id.length > 0); + +export const IOS_RUNNER_CONTAINER_BUNDLE_IDS: string[] = Array.from(new Set(iosRunnerContainerBundleIds)); + type RunnerCommand = { command: | 'tap' diff --git a/src/utils/interactors.ts b/src/utils/interactors.ts index 32555ce6f..2386ef328 100644 --- a/src/utils/interactors.ts +++ b/src/utils/interactors.ts @@ -44,7 +44,7 @@ type Interactor = { fill(x: number, y: number, text: string): Promise; scroll(direction: string, amount?: number): Promise; scrollIntoView(text: string): Promise<{ attempts?: number } | void>; - screenshot(outPath: string): Promise; + screenshot(outPath: string, appBundleId?: string): Promise; }; export function getInteractor(device: DeviceInfo, runnerContext: RunnerContext): Interactor { @@ -66,14 +66,14 @@ export function getInteractor(device: DeviceInfo, runnerContext: RunnerContext): fill: (x, y, text) => fillAndroid(device, x, y, text), scroll: (direction, amount) => scrollAndroid(device, direction, amount), scrollIntoView: (text) => scrollIntoViewAndroid(device, text), - screenshot: (outPath) => screenshotAndroid(device, outPath), + screenshot: (outPath, _appBundleId) => screenshotAndroid(device, outPath), }; case 'ios': return { open: (app, options) => openIosApp(device, app, { appBundleId: options?.appBundleId, url: options?.url }), openDevice: () => openIosDevice(device), close: (app) => closeIosApp(device, app), - screenshot: (outPath) => screenshotIos(device, outPath), + screenshot: (outPath, appBundleId) => screenshotIos(device, outPath, appBundleId), ...iosRunnerOverrides(device, runnerContext), }; default: