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 @@ -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"))
Expand Down
2 changes: 1 addition & 1 deletion src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down
20 changes: 1 addition & 19 deletions src/daemon/handlers/record-trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 | undefined>): string[] {
const seen = new Set<string>();
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;

Expand Down
45 changes: 42 additions & 3 deletions src/platforms/ios/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, string> = {
Expand Down Expand Up @@ -215,7 +215,7 @@ export async function reinstallIosApp(
return { bundleId };
}

export async function screenshotIos(device: DeviceInfo, outPath: string): Promise<void> {
export async function screenshotIos(device: DeviceInfo, outPath: string, appBundleId?: string): Promise<void> {
if (device.kind === 'simulator') {
await ensureBootedSimulator(device);
await runCmd('xcrun', ['simctl', 'io', device.id, 'screenshot', outPath]);
Expand All @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions src/platforms/ios/runner-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 3 additions & 3 deletions src/utils/interactors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ type Interactor = {
fill(x: number, y: number, text: string): Promise<void>;
scroll(direction: string, amount?: number): Promise<void>;
scrollIntoView(text: string): Promise<{ attempts?: number } | void>;
screenshot(outPath: string): Promise<void>;
screenshot(outPath: string, appBundleId?: string): Promise<void>;
};

export function getInteractor(device: DeviceInfo, runnerContext: RunnerContext): Interactor {
Expand All @@ -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:
Expand Down
Loading