diff --git a/CONTEXT.md b/CONTEXT.md index 6956b4816..134635fde 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -45,6 +45,10 @@ such as `proxy`, cloud bridge, or `limrun`. - Runner/process lease: backend helper mutual-exclusion guard for platform runners or tools; it is not the remote client ownership boundary. +- iOS physical-device control: Apple-local module selected from discovery evidence. CoreDevice + devices retain the `devicectl` controller; devices found only by `xctrace` use the XCTest + controller for readiness, app activation/termination, and cable-bound usbmux runner transport + without claiming unsupported app inventory or installation capabilities. - Host process primitive: low-level host PID helpers in `src/utils/host-process.ts` for liveness, start-time/command reads, process listing, process-tree expansion, PID de-duplication, and best-effort signaling. It must not own domain cleanup policy such as browser ownership markers, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index a67b5a600..518e17694 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -1362,6 +1362,28 @@ extension RunnerTests { } case .uptime: return executeUptime() + case .activate: + guard + let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleId.isEmpty + else { + return Response(ok: false, error: ErrorPayload(message: "activate requires appBundleId")) + } + // prepareActiveCommandContext already activated this bundle. Keep this case as the + // explicit acknowledgement after that preflight, not as a second activation. + return Response(ok: true, data: DataPayload(message: "app activated")) + case .terminate: + guard + let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleId.isEmpty + else { + return Response(ok: false, error: ErrorPayload(message: "terminate requires appBundleId")) + } + XCUIApplication(bundleIdentifier: bundleId).terminate() + if currentBundleId == bundleId { + invalidateCachedTarget(reason: "target_terminated") + } + return Response(ok: true, data: DataPayload(message: "app terminated")) default: break } @@ -1451,7 +1473,7 @@ extension RunnerTests { ) throws -> Response { var activeApp = activeApp switch command.command { - case .status, .targetReset, .shutdown, .recordStart, .recordStop, .uptime: + case .status, .activate, .terminate, .targetReset, .shutdown, .recordStart, .recordStop, .uptime: return Response( ok: false, error: ErrorPayload( @@ -1838,6 +1860,12 @@ extension RunnerTests { guard let pngData = runnerPngData(for: screenshot.image) else { return Response(ok: false, error: ErrorPayload(message: "Failed to encode screenshot as PNG")) } + if command.inlineScreenshot == true { + return Response( + ok: true, + data: DataPayload(imageBase64: pngData.base64EncodedString()) + ) + } let fileName = "screenshot-\(Int(Date().timeIntervalSince1970 * 1000)).png" let filePath = (NSTemporaryDirectory() as NSString).appendingPathComponent(fileName) do { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift index b8a60ec31..2d7aa080c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift @@ -147,7 +147,7 @@ final class RunnerCommandJournal { .remotePress, .type, .swipe, .scroll, .desktopScroll, .findText, .querySelector, .readText, .back, .backInApp, .backSystem, .home, .rotate, .appSwitcher, .keyboardDismiss, .keyboardReturn, .alert, .sequence, .gesture, .gestureViewport, .recordStart, .recordStop, - .status, .uptime, .targetReset, .shutdown: + .status, .uptime, .activate, .terminate, .targetReset, .shutdown: return true } } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index ffa2076e7..086b42ddf 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -31,6 +31,8 @@ enum CommandType: String, Codable { case recordStop case status case uptime + case activate + case terminate case targetReset case shutdown } @@ -90,7 +92,7 @@ extension CommandType { return CommandTraits(isInteraction: false, readOnly: .conditional, isLifecycle: false) // Runner-lifecycle commands: skip the app-activation preflight. - case .recordStop, .uptime, .targetReset, .shutdown: + case .recordStop, .uptime, .terminate, .targetReset, .shutdown: return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: true) case .status: @@ -101,7 +103,7 @@ extension CommandType { // guard interacts with bespoke macOS activation, so classifying it needs a macOS smoke // check first (tracked as a follow-up). Also preserved: querySelector is NOT read-only; // recordStart is NOT a lifecycle command; home/alert remain non-interaction by design. - case .mouseClick, .querySelector, .home, .recordStart: + case .mouseClick, .querySelector, .home, .recordStart, .activate: return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: false) } } @@ -140,6 +142,7 @@ struct Command: Codable { let scope: String? let raw: Bool? let fullscreen: Bool? + let inlineScreenshot: Bool? let synthesized: Bool? let steps: [SequenceStep]? } @@ -227,6 +230,7 @@ extension Response { struct DataPayload: Codable { let message: String? + let imageBase64: String? let text: String? let found: Bool? let items: [String]? @@ -266,6 +270,7 @@ struct DataPayload: Codable { init( message: String? = nil, + imageBase64: String? = nil, text: String? = nil, found: Bool? = nil, items: [String]? = nil, @@ -304,6 +309,7 @@ struct DataPayload: Codable { sequenceResults: [SequenceStepResult]? = nil ) { self.message = message + self.imageBase64 = imageBase64 self.text = text self.found = found self.items = items diff --git a/src/cli/parser/__tests__/cli-help-topics.test.ts b/src/cli/parser/__tests__/cli-help-topics.test.ts index b1334335b..1ac0992c4 100644 --- a/src/cli/parser/__tests__/cli-help-topics.test.ts +++ b/src/cli/parser/__tests__/cli-help-topics.test.ts @@ -468,6 +468,9 @@ test('usageForCommand resolves physical-device help topic', async () => { assert.match(help, /AGENT_DEVICE_IOS_TEAM_ID=ABCDE12345/); assert.match(help, /AGENT_DEVICE_IOS_BUNDLE_ID=com\.yourname\.agentdevice\.runner/); assert.match(help, /profile name\/specifier, not a file path/); + assert.match(help, /Older devices visible only to xctrace use the XCTest backend automatically/); + assert.match(help, /runner commands travel through macOS usbmuxd/); + assert.match(help, /app inventory, install\/reinstall, deep links, and launch arguments/); }); test('usageForCommand resolves manual QA help topic', async () => { diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 8a2e75160..1b1d7546b 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -799,8 +799,11 @@ Discovery: Use --device only when multiple devices are present. iOS physical-device prerequisites: - Xcode and xcrun devicectl must be available from the selected Xcode. + Xcode, xcrun xcdevice, and xcrun xctrace must be available from the selected Xcode. The device must be paired/trusted, connected, unlocked when needed, and have Developer Mode enabled. + Modern devices visible to devicectl use CoreDevice. Older devices visible only to xctrace use the XCTest backend automatically. + XCTest-backed devices must already have the target app installed and should be opened by bundle ID; app inventory, install/reinstall, deep links, and launch arguments require CoreDevice. + XCTest-backed runner commands travel through macOS usbmuxd; keep the trusted device connected by cable. The AgentDeviceRunner XCTest host must be signed before commands can run on a physical device. Start with Automatic Signing and only these env vars: AGENT_DEVICE_IOS_TEAM_ID=ABCDE12345 diff --git a/src/core/__tests__/dispatch-resolve.test.ts b/src/core/__tests__/dispatch-resolve.test.ts index 669eff05b..dac4bd9c8 100644 --- a/src/core/__tests__/dispatch-resolve.test.ts +++ b/src/core/__tests__/dispatch-resolve.test.ts @@ -181,6 +181,16 @@ test('resolveTargetDevice uses injected device inventory without local discovery assert.equal(mockListAppleDevices.mock.calls.length, 0); }); +test('resolveTargetDevice preserves physical-device backend evidence from injected inventory', async () => { + const result = await withDeviceInventoryProvider( + async () => [{ ...physical, iosPhysicalDeviceBackend: 'xctest' }], + async () => await resolveTargetDevice({ platform: 'ios', udid: physical.id }), + ); + + assert.equal(result.iosPhysicalDeviceBackend, 'xctest'); + assert.equal(mockListAppleDevices.mock.calls.length, 0); +}); + test('resolveTargetDevice preserves Apple simulator preference with injected inventory', async () => { mockFindBootableIosSimulator.mockResolvedValue(simulator); diff --git a/src/daemon/__tests__/device-ready.test.ts b/src/daemon/__tests__/device-ready.test.ts index 2eb0895b9..5a71bc5d3 100644 --- a/src/daemon/__tests__/device-ready.test.ts +++ b/src/daemon/__tests__/device-ready.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; -import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; import type { DeviceInfo } from '../../kernel/device.ts'; @@ -19,12 +18,7 @@ import { runCmd } from '../../utils/exec.ts'; import { waitForAndroidBoot } from '../../platforms/android/devices.ts'; import { ensureBootedSimulator } from '../../platforms/apple/core/simulator.ts'; import { ANDROID_EMULATOR, IOS_DEVICE, IOS_SIMULATOR } from '../../__tests__/test-utils/index.ts'; -import { - DEVICE_READY_CACHE_TTL_MS, - ensureDeviceReady, - parseIosReadyPayload, - resolveIosReadyHint, -} from '../device-ready.ts'; +import { DEVICE_READY_CACHE_TTL_MS, ensureDeviceReady } from '../device-ready.ts'; const mockRunCmd = vi.mocked(runCmd); const mockEnsureBootedSimulator = vi.mocked(ensureBootedSimulator); @@ -130,52 +124,3 @@ test('ensureDeviceReady does not cache failed readiness checks', async () => { expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(2); }); - -test('parseIosReadyPayload reads tunnelState from direct connectionProperties', () => { - const parsed = parseIosReadyPayload({ - result: { - connectionProperties: { - tunnelState: 'connected', - }, - }, - }); - assert.equal(parsed.tunnelState, 'connected'); -}); - -test('parseIosReadyPayload reads tunnelState from nested device connectionProperties', () => { - const parsed = parseIosReadyPayload({ - result: { - device: { - connectionProperties: { - tunnelState: 'connecting', - }, - }, - }, - }); - assert.equal(parsed.tunnelState, 'connecting'); -}); - -test('parseIosReadyPayload returns empty payload for malformed input', () => { - assert.deepEqual(parseIosReadyPayload(null), {}); - assert.deepEqual(parseIosReadyPayload({}), {}); - assert.deepEqual( - parseIosReadyPayload({ - result: { connectionProperties: { tunnelState: 123 } }, - }), - {}, - ); -}); - -test('resolveIosReadyHint maps known connection errors', () => { - const connecting = resolveIosReadyHint('', 'Device is busy (Connecting to iPhone)'); - assert.match(connecting, /still connecting/i); - - const coreDeviceTimeout = resolveIosReadyHint('CoreDeviceService timed out', ''); - assert.match(coreDeviceTimeout, /coredevice service/i); -}); - -test('resolveIosReadyHint falls back to generic guidance', () => { - const hint = resolveIosReadyHint('unexpected failure', ''); - assert.match(hint, /unlocked/i); - assert.match(hint, /xcode/i); -}); diff --git a/src/daemon/device-ready.ts b/src/daemon/device-ready.ts index 11110e94a..6828a8471 100644 --- a/src/daemon/device-ready.ts +++ b/src/daemon/device-ready.ts @@ -1,20 +1,8 @@ import { isIosFamily, type DeviceInfo } from '../kernel/device.ts'; -import os from 'node:os'; -import path from 'node:path'; -import { promises as fs } from 'node:fs'; -import { AppError } from '../kernel/errors.ts'; -import { - resolveIosDevicectlHint, - IOS_DEVICECTL_DEFAULT_HINT, -} from '../platforms/apple/core/devicectl.ts'; -import { runXcrun } from '../platforms/apple/core/tool-provider.ts'; +import { resolveIosPhysicalDeviceControl } from '../platforms/apple/core/physical-device-control.ts'; import { isActiveProviderDevice } from '../provider-device-runtime.ts'; -import { execFailureDetails } from '../utils/exec.ts'; import { createTtlMemo } from '../utils/ttl-memo.ts'; -const IOS_DEVICE_READY_TIMEOUT_MS = 15_000; -const IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS = 3_000; - // Exported so unit tests can assert TTL behavior without duplicating the value. export const DEVICE_READY_CACHE_TTL_MS = 5_000; @@ -51,7 +39,7 @@ export async function ensureDeviceReady( return; } if (device.kind === 'device') { - await ensureIosDeviceReady(device.id); + await resolveIosPhysicalDeviceControl(device).ensureReady(device); markDeviceReady(cacheKey); return; } @@ -77,141 +65,3 @@ function deviceReadyCacheKey(device: DeviceInfo): string { simulatorSetPath, ]); } - -async function ensureIosDeviceReady(deviceId: string): Promise { - const jsonPath = path.join( - os.tmpdir(), - `agent-device-ready-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, - ); - const timeoutSeconds = Math.max(1, Math.ceil(IOS_DEVICE_READY_TIMEOUT_MS / 1000)); - try { - const result = await runXcrun( - [ - 'devicectl', - 'device', - 'info', - 'details', - '--device', - deviceId, - '--json-output', - jsonPath, - '--timeout', - String(timeoutSeconds), - ], - { - allowFailure: true, - timeoutMs: IOS_DEVICE_READY_TIMEOUT_MS + IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS, - }, - ); - const { stdout, stderr } = result; - const parsed = await readIosReadyPayload(jsonPath); - if (result.exitCode === 0) { - if (!parsed.parsed) { - // exec-guard-allow: thrown at exit 0 (probe succeeded but the readiness - // JSON was missing/invalid) — not a process-exit wrap. - throw new AppError('COMMAND_FAILED', 'iOS device readiness probe failed', { - kind: 'probe_inconclusive', - deviceId, - stdout, - stderr, - hint: 'CoreDevice returned success but readiness JSON output was missing or invalid. Retry; if it persists restart Xcode and the iOS device.', - }); - } - const tunnelState = parsed?.tunnelState?.toLowerCase(); - if (tunnelState === 'connecting') { - throw new AppError('COMMAND_FAILED', 'iOS device is not ready for automation', { - kind: 'not_ready', - deviceId, - tunnelState, - hint: 'Device tunnel is still connecting. Keep the device unlocked and connected by cable until it is fully available in Xcode Devices, then retry.', - }); - } - return; - } - throw new AppError( - 'COMMAND_FAILED', - 'iOS device is not ready for automation', - execFailureDetails(result, { - kind: 'not_ready', - deviceId, - tunnelState: parsed?.tunnelState, - hint: resolveIosReadyHint(stdout, stderr), - }), - ); - } catch (error) { - if (error instanceof AppError && error.code === 'COMMAND_FAILED') { - const kind = typeof error.details?.kind === 'string' ? error.details.kind : ''; - if (kind === 'not_ready') { - throw error; - } - const details = (error.details ?? {}) as { - stdout?: string; - stderr?: string; - timeoutMs?: number; - }; - const stdout = String(details.stdout ?? ''); - const stderr = String(details.stderr ?? ''); - const timeoutMs = Number(details.timeoutMs ?? IOS_DEVICE_READY_TIMEOUT_MS); - const timeoutHint = `CoreDevice did not respond within ${timeoutMs}ms. Keep the device unlocked and trusted, then retry; if it persists restart Xcode and the iOS device.`; - throw new AppError( - 'COMMAND_FAILED', - 'iOS device readiness probe failed', - { - deviceId, - cause: error.message, - timeoutMs, - stdout, - stderr, - hint: stdout || stderr ? resolveIosReadyHint(stdout, stderr) : timeoutHint, - }, - error, - ); - } - throw new AppError( - 'COMMAND_FAILED', - 'iOS device readiness probe failed', - { - deviceId, - hint: 'Reconnect the device, keep it unlocked, and retry.', - }, - error instanceof Error ? error : undefined, - ); - } finally { - await fs.rm(jsonPath, { force: true }).catch(() => {}); - } -} - -export function parseIosReadyPayload(payload: unknown): { tunnelState?: string } { - const result = (payload as { result?: unknown } | null | undefined)?.result; - if (!result || typeof result !== 'object') return {}; - const direct = (result as { connectionProperties?: { tunnelState?: unknown } }) - .connectionProperties?.tunnelState; - const nested = (result as { device?: { connectionProperties?: { tunnelState?: unknown } } }) - .device?.connectionProperties?.tunnelState; - const tunnelState = - typeof direct === 'string' ? direct : typeof nested === 'string' ? nested : undefined; - return tunnelState ? { tunnelState } : {}; -} - -async function readIosReadyPayload( - jsonPath: string, -): Promise<{ parsed: boolean; tunnelState?: string }> { - try { - const payloadText = await fs.readFile(jsonPath, 'utf8'); - const payload = JSON.parse(payloadText) as unknown; - const parsed = parseIosReadyPayload(payload); - return { parsed: true, tunnelState: parsed.tunnelState }; - } catch { - return { parsed: false }; - } -} - -export function resolveIosReadyHint(stdout: string, stderr: string): string { - const devicectlHint = resolveIosDevicectlHint(stdout, stderr); - if (devicectlHint) return devicectlHint; - const text = `${stdout}\n${stderr}`.toLowerCase(); - if (text.includes('timed out waiting for all destinations')) { - return 'Xcode destination did not become available in time. Keep device unlocked and retry.'; - } - return IOS_DEVICECTL_DEFAULT_HINT; -} diff --git a/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts b/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts index 7e308a8c9..b21458478 100644 --- a/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts +++ b/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts @@ -93,6 +93,14 @@ test('devices omits appleOs for non-Apple devices', async () => { expect(ios?.appleOs).toBe('ios'); }); +test('devices omits internal physical-device backend evidence', async () => { + const devices = await listPublicDevices([ + { ...IOS_SIMULATOR, iosPhysicalDeviceBackend: 'xctest' }, + ]); + + expect(devices[0]).not.toHaveProperty('iosPhysicalDeviceBackend'); +}); + test('devices drops a stray appleOs on a non-Apple device (gated to Apple platforms)', async () => { // Regression: appleOs is Apple-only. A malformed/legacy NON-Apple record carrying a // valid Apple OS value must NOT surface it — the projection gates on the platform, diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index aefb448cb..755d5398b 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -179,6 +179,7 @@ export async function handleSessionInventoryCommands(params: { function publicDeviceInfo({ simulatorSetPath: _simulatorSetPath, + iosPhysicalDeviceBackend: _iosPhysicalDeviceBackend, appleOs, ...device }: DeviceInfo): Record { diff --git a/src/kernel/device.ts b/src/kernel/device.ts index 3c9c3db92..66e0acf4b 100644 --- a/src/kernel/device.ts +++ b/src/kernel/device.ts @@ -38,6 +38,9 @@ export type DeviceInfo = { appleOs?: AppleOS; booted?: boolean; simulatorSetPath?: string; + // Internal physical-iOS execution backend selected during discovery. + // Public device projections strip this field. + iosPhysicalDeviceBackend?: 'coredevice' | 'xctest'; }; export type DeviceSelector = { diff --git a/src/platforms/apple/core/__tests__/devices.test.ts b/src/platforms/apple/core/__tests__/devices.test.ts index 37493d411..f935e2de5 100644 --- a/src/platforms/apple/core/__tests__/devices.test.ts +++ b/src/platforms/apple/core/__tests__/devices.test.ts @@ -183,6 +183,7 @@ test('parseXctracePhysicalAppleDevices parses only physical devices from the Dev kind: 'device', target: 'mobile', appleOs: 'ios', + iosPhysicalDeviceBackend: 'xctest', booted: true, }, { @@ -192,6 +193,7 @@ test('parseXctracePhysicalAppleDevices parses only physical devices from the Dev kind: 'device', target: 'tv', appleOs: 'tvos', + iosPhysicalDeviceBackend: 'xctest', booted: true, }, ]); @@ -209,6 +211,7 @@ test('parseXctracePhysicalAppleDevices tags physical iPads as iPadOS', () => { kind: 'device', target: 'mobile', appleOs: 'ipados', + iosPhysicalDeviceBackend: 'xctest', booted: true, }, ]); @@ -226,6 +229,7 @@ test('parseXctracePhysicalAppleDevices tags Apple Vision devices as visionOS', ( kind: 'device', target: 'mobile', appleOs: 'visionos', + iosPhysicalDeviceBackend: 'xctest', booted: true, }, ]); @@ -249,6 +253,7 @@ test('parseXctracePhysicalAppleDevices parses the parenthesized physical device kind: 'device', target: 'mobile', appleOs: 'ios', + iosPhysicalDeviceBackend: 'xctest', booted: true, }, { @@ -258,6 +263,7 @@ test('parseXctracePhysicalAppleDevices parses the parenthesized physical device kind: 'device', target: 'mobile', appleOs: 'ipados', + iosPhysicalDeviceBackend: 'xctest', booted: true, }, { @@ -267,6 +273,7 @@ test('parseXctracePhysicalAppleDevices parses the parenthesized physical device kind: 'device', target: 'tv', appleOs: 'tvos', + iosPhysicalDeviceBackend: 'xctest', booted: true, }, ]); @@ -285,6 +292,7 @@ test('parseXctracePhysicalAppleDevices preserves parentheses in bracket-format n kind: 'device', target: 'mobile', appleOs: 'ios', + iosPhysicalDeviceBackend: 'xctest', booted: true, }, { @@ -294,6 +302,7 @@ test('parseXctracePhysicalAppleDevices preserves parentheses in bracket-format n kind: 'device', target: 'mobile', appleOs: 'ios', + iosPhysicalDeviceBackend: 'xctest', booted: true, }, ]); @@ -602,6 +611,7 @@ test('listAppleDevices falls back to xctrace parenthesized devices when devicect assert.equal(physicalDevices.length, 1); assert.equal(physicalDevices[0]?.id, '00008020-001C2D2234567890'); assert.equal(physicalDevices[0]?.name, 'iPhone 8 Plus'); + assert.equal(physicalDevices[0]?.iosPhysicalDeviceBackend, 'xctest'); }); test('listAppleDevices keeps physical discovery disabled for simulator-set scoped runs', async () => { diff --git a/src/platforms/apple/core/__tests__/physical-device-control.test.ts b/src/platforms/apple/core/__tests__/physical-device-control.test.ts new file mode 100644 index 000000000..bfcbacfb4 --- /dev/null +++ b/src/platforms/apple/core/__tests__/physical-device-control.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'vitest'; +import { IOS_DEVICE as SHARED_IOS_DEVICE } from '../../../../__tests__/test-utils/index.ts'; +import type { DeviceInfo } from '../../../../kernel/device.ts'; +import { installIosApp } from '../app-install.ts'; +import { closeIosApp, openIosApp } from '../app-launch.ts'; +import { listIosApps } from '../app-resolution.ts'; +import { resolveIosPhysicalDeviceControl } from '../physical-device-control.ts'; +import { withAppleRunnerProvider } from '../runner/runner-provider.ts'; +import { captureScreenshotViaRunner } from '../screenshot.ts'; +import { withAppleToolProvider } from '../tool-provider.ts'; + +const IOS_DEVICE: DeviceInfo = { + ...SHARED_IOS_DEVICE, + id: 'legacy-ios-device', + name: 'Legacy iPhone', +}; + +const XCTEST_IOS_DEVICE: DeviceInfo = { + ...IOS_DEVICE, + iosPhysicalDeviceBackend: 'xctest', +}; + +test('physical-device backend defaults to CoreDevice and honors discovery evidence', () => { + assert.equal(resolveIosPhysicalDeviceControl(IOS_DEVICE).backend, 'coredevice'); + assert.equal(resolveIosPhysicalDeviceControl(XCTEST_IOS_DEVICE).backend, 'xctest'); +}); + +test('XCTest readiness uses xcdevice instead of devicectl', async () => { + const calls: Array<{ cmd: string; args: string[] }> = []; + + await withAppleToolProvider( + async (cmd, args) => { + calls.push({ cmd, args }); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + async () => + await resolveIosPhysicalDeviceControl(XCTEST_IOS_DEVICE).ensureReady(XCTEST_IOS_DEVICE), + ); + + assert.deepEqual(calls, [ + { + cmd: 'xcrun', + args: ['xcdevice', 'wait', '--both', '--timeout=15', XCTEST_IOS_DEVICE.id], + }, + ]); + assert.deepEqual( + await resolveIosPhysicalDeviceControl(XCTEST_IOS_DEVICE).resolveRunnerTransport( + XCTEST_IOS_DEVICE, + ), + { kind: 'usbmux' }, + ); +}); + +test('app lifecycle uses runner commands for an xctrace-only physical device', async () => { + const commands: unknown[] = []; + + await withAppleRunnerProvider( + async (_device, command) => { + commands.push(command); + return { message: 'app activated' }; + }, + { deviceId: XCTEST_IOS_DEVICE.id }, + async () => { + await openIosApp(XCTEST_IOS_DEVICE, 'com.example.app', { + appBundleId: 'com.example.app', + }); + await closeIosApp(XCTEST_IOS_DEVICE, 'com.example.app'); + }, + ); + + assert.equal(commands.length, 2); + assert.deepEqual( + commands.map((command) => { + const { commandId: _commandId, ...rest } = command as Record; + return rest; + }), + [ + { command: 'activate', appBundleId: 'com.example.app' }, + { command: 'terminate', appBundleId: 'com.example.app' }, + ], + ); +}); + +test('runner screenshots stay in-band when CoreDevice file copy is unavailable', async () => { + const outPath = path.join( + await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-xctest-screenshot-')), + 'screen.png', + ); + try { + await withAppleRunnerProvider( + async (_device, command) => { + assert.equal(command.inlineScreenshot, true); + return { imageBase64: Buffer.from('png-bytes').toString('base64') }; + }, + { deviceId: XCTEST_IOS_DEVICE.id }, + async () => + await captureScreenshotViaRunner(XCTEST_IOS_DEVICE, outPath, 'com.example.app', undefined), + ); + + assert.equal(await fs.readFile(outPath, 'utf8'), 'png-bytes'); + } finally { + await fs.rm(path.dirname(outPath), { recursive: true, force: true }); + } +}); + +test('CoreDevice-only app inventory and install fail with targeted XCTest guidance', async () => { + await assert.rejects( + () => listIosApps(XCTEST_IOS_DEVICE, 'all'), + /App inventory is unavailable on this XCTest-backed physical iOS device/, + ); + await assert.rejects( + () => installIosApp(XCTEST_IOS_DEVICE, '/missing/example.app'), + /Installing apps is unavailable on this XCTest-backed physical iOS device/, + ); +}); diff --git a/src/platforms/apple/core/__tests__/physical-device-coredevice.test.ts b/src/platforms/apple/core/__tests__/physical-device-coredevice.test.ts new file mode 100644 index 000000000..0c3f1f3bb --- /dev/null +++ b/src/platforms/apple/core/__tests__/physical-device-coredevice.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + parseIosDeviceDetailsPayload, + resolveIosReadyHint, +} from '../physical-device-coredevice.ts'; + +test('parseIosDeviceDetailsPayload reads direct and nested tunnel state', () => { + assert.equal( + parseIosDeviceDetailsPayload({ + result: { connectionProperties: { tunnelState: 'connected' } }, + }).tunnelState, + 'connected', + ); + assert.equal( + parseIosDeviceDetailsPayload({ + result: { device: { connectionProperties: { tunnelState: 'connecting' } } }, + }).tunnelState, + 'connecting', + ); +}); + +test('parseIosDeviceDetailsPayload ignores malformed values', () => { + assert.deepEqual(parseIosDeviceDetailsPayload(null), {}); + assert.deepEqual(parseIosDeviceDetailsPayload({}), {}); + assert.deepEqual( + parseIosDeviceDetailsPayload({ + result: { connectionProperties: { tunnelState: 123 } }, + }), + {}, + ); +}); + +test('resolveIosReadyHint maps known connection errors', () => { + assert.match( + resolveIosReadyHint('', 'Device is busy (Connecting to iPhone)'), + /still connecting/i, + ); + assert.match(resolveIosReadyHint('CoreDeviceService timed out', ''), /coredevice service/i); +}); + +test('resolveIosReadyHint falls back to generic guidance', () => { + const hint = resolveIosReadyHint('unexpected failure', ''); + assert.match(hint, /unlocked/i); + assert.match(hint, /xcode/i); +}); diff --git a/src/platforms/apple/core/__tests__/runner-client.test.ts b/src/platforms/apple/core/__tests__/runner-client.test.ts index 403906b1e..21c8f7b73 100644 --- a/src/platforms/apple/core/__tests__/runner-client.test.ts +++ b/src/platforms/apple/core/__tests__/runner-client.test.ts @@ -178,6 +178,8 @@ const runnerProtocolCommandFixtures: Record test('runner protocol fixtures cover every runner command with JSON-safe samples', () => { const commands = Object.keys(runnerProtocolCommandFixtures).sort(); assert.deepEqual(commands, [ + 'activate', 'alert', 'appSwitcher', 'back', @@ -349,6 +352,7 @@ test('runner protocol fixtures cover every runner command with JSON-safe samples 'swipe', 'tap', 'targetReset', + 'terminate', 'type', 'uptime', ]); diff --git a/src/platforms/apple/core/__tests__/runner-command-traits.test.ts b/src/platforms/apple/core/__tests__/runner-command-traits.test.ts index 762a4b32d..2c87a4817 100644 --- a/src/platforms/apple/core/__tests__/runner-command-traits.test.ts +++ b/src/platforms/apple/core/__tests__/runner-command-traits.test.ts @@ -47,7 +47,11 @@ test('runner command manifest pins lifecycle-sensitive command groups', () => { 'snapshot', ]); assert.deepEqual(commandsForClass('readOnlyReadinessProbe'), ['status', 'uptime']); - assert.deepEqual(commandsForClass('readinessPreflightExemptMutation'), ['targetReset']); + assert.deepEqual(commandsForClass('readinessPreflightExemptMutation'), [ + 'activate', + 'targetReset', + 'terminate', + ]); }); test('runner command trait helpers read from the shared trait table', () => { diff --git a/src/platforms/apple/core/__tests__/runner-transport.test.ts b/src/platforms/apple/core/__tests__/runner-transport.test.ts index dc1823229..084c617bc 100644 --- a/src/platforms/apple/core/__tests__/runner-transport.test.ts +++ b/src/platforms/apple/core/__tests__/runner-transport.test.ts @@ -6,8 +6,9 @@ import type { ExecBackgroundResult } from '../../../../utils/exec.ts'; import { AppError } from '../../../../kernel/errors.ts'; import type { RunnerSession } from '../runner/runner-session-types.ts'; -const { mockRunCmd } = vi.hoisted(() => ({ +const { mockRunCmd, mockUsbmuxPostCommand } = vi.hoisted(() => ({ mockRunCmd: vi.fn(), + mockUsbmuxPostCommand: vi.fn(), })); vi.mock('../../../../utils/exec.ts', async () => { @@ -20,11 +21,14 @@ vi.mock('../../../../utils/exec.ts', async () => { }; }); -import { - clearDeviceTunnelIpCache, - sendRunnerCommandOnce, - waitForRunner, -} from '../runner/runner-transport.ts'; +vi.mock('../runner/runner-usbmux.ts', () => ({ + usbmuxRunnerTransport: { + postCommand: mockUsbmuxPostCommand, + }, +})); + +import { clearDeviceTunnelIpCache } from '../runner/runner-command-route.ts'; +import { sendRunnerCommandOnce, waitForRunner } from '../runner/runner-transport.ts'; const iosSimulator: DeviceInfo = { platform: 'apple', @@ -42,9 +46,16 @@ const iosDevice: DeviceInfo = { booted: true, }; +const xctestIosDevice: DeviceInfo = { + ...iosDevice, + iosPhysicalDeviceBackend: 'xctest', +}; + beforeEach(() => { clearDeviceTunnelIpCache(); mockRunCmd.mockReset(); + mockUsbmuxPostCommand.mockReset(); + mockUsbmuxPostCommand.mockResolvedValue(new Response('{}')); }); afterEach(() => { @@ -198,6 +209,21 @@ test('sendRunnerCommandOnce does not retry or simulator fallback after request f assert.equal(mockRunCmd.mock.calls.length, 0); }); +test('sendRunnerCommandOnce routes xctest physical devices through usbmux', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const response = await sendRunnerCommandOnce(xctestIosDevice, 8100, { command: 'uptime' }, 5_000); + + assert.equal(response.status, 200); + assert.equal(fetchMock.mock.calls.length, 0); + assert.deepEqual(mockUsbmuxPostCommand.mock.calls[0]?.slice(0, 3), [ + xctestIosDevice.id, + 8100, + { command: 'uptime' }, + ]); +}); + function stubSuccessfulFetch(): void { vi.stubGlobal( 'fetch', diff --git a/src/platforms/apple/core/__tests__/runner-usbmux.test.ts b/src/platforms/apple/core/__tests__/runner-usbmux.test.ts new file mode 100644 index 000000000..b8f768708 --- /dev/null +++ b/src/platforms/apple/core/__tests__/runner-usbmux.test.ts @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import net, { type Server, type Socket } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, test } from 'vitest'; +import { createUsbmuxRunnerTransport } from '../runner/runner-usbmux.ts'; + +const DEVICE_UDID = '00008020-001C2D2234567890'; +const USBMUX_DEVICE_ID = 42; +const RUNNER_PORT = 51_234; +const openServers: Server[] = []; +const socketDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + openServers.splice(0).map( + async (server) => + await new Promise((resolve) => { + server.close(() => resolve()); + }), + ), + ); + await Promise.all( + socketDirectories + .splice(0) + .map(async (directory) => await fs.rm(directory, { force: true, recursive: true })), + ); +}); + +test('xctest runner commands use usbmux device lookup and port transport', async () => { + const socketPath = await createSocketPath(); + const { done: serverDone } = await createFakeUsbmuxd( + socketPath, + async (socket, connectionIndex) => { + if (connectionIndex === 0) { + const request = await readPacket(socket); + assert.match(request, /ListDevices<\/string>/); + socket.end( + buildPacket( + `DeviceList + DeviceID${USBMUX_DEVICE_ID} + Properties + SerialNumber${DEVICE_UDID} + + `, + ), + ); + return; + } + + const connectRequest = await readPacket(socket); + assert.match(connectRequest, /Connect<\/string>/); + assert.match( + connectRequest, + new RegExp(`DeviceID${USBMUX_DEVICE_ID}`), + ); + assert.match( + connectRequest, + new RegExp(`PortNumber${hostToNetworkPort(RUNNER_PORT)}`), + ); + socket.write( + buildPacket( + 'MessageTypeResultNumber0', + ), + ); + + const requestBody = await readHttpRequestBody(socket); + assert.deepEqual(JSON.parse(requestBody), { + command: 'uptime', + commandId: 'runner-usbmux-test', + }); + const body = JSON.stringify({ ok: true, data: { uptimeMs: 123 } }); + socket.end( + [ + 'HTTP/1.1 200 OK', + 'Content-Type: application/json', + `Content-Length: ${Buffer.byteLength(body)}`, + 'Connection: close', + '', + body, + ].join('\r\n'), + ); + }, + ); + + const response = await createUsbmuxRunnerTransport(socketPath).postCommand( + DEVICE_UDID, + RUNNER_PORT, + { command: 'uptime', commandId: 'runner-usbmux-test' }, + 2_000, + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true, data: { uptimeMs: 123 } }); + await serverDone; +}); + +async function createSocketPath(): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-usbmux-test-')); + const socketPath = path.join(directory, 'usbmuxd.sock'); + socketDirectories.push(directory); + return socketPath; +} + +async function createFakeUsbmuxd( + socketPath: string, + handleConnection: (socket: Socket, connectionIndex: number) => Promise, +): Promise<{ done: Promise }> { + let connectionIndex = 0; + let resolveDone!: () => void; + let rejectDone!: (error: unknown) => void; + const done = new Promise((resolve, reject) => { + resolveDone = resolve; + rejectDone = reject; + }); + const server = net.createServer((socket) => { + const currentIndex = connectionIndex++; + handleConnection(socket, currentIndex).then(() => { + if (currentIndex === 1) resolveDone(); + }, rejectDone); + }); + server.on('error', rejectDone); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + openServers.push(server); + return { done }; +} + +async function readPacket(socket: Socket): Promise { + const packet = await readUntil(socket, (buffer) => { + if (buffer.length < 16) return undefined; + const length = buffer.readUInt32LE(0); + return buffer.length >= length ? buffer.subarray(16, length) : undefined; + }); + return packet.toString('utf8'); +} + +function buildPacket(xml: string): Buffer { + const payload = Buffer.from(xml, 'utf8'); + const packet = Buffer.alloc(16 + payload.length); + packet.writeUInt32LE(packet.length, 0); + packet.writeUInt32LE(1, 4); + packet.writeUInt32LE(8, 8); + packet.writeUInt32LE(1, 12); + payload.copy(packet, 16); + return packet; +} + +async function readHttpRequestBody(socket: Socket): Promise { + const body = await readUntil(socket, (buffer) => { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd < 0) return undefined; + const headers = buffer.subarray(0, headerEnd).toString('utf8'); + const contentLength = Number(/^content-length:\s*(\d+)$/im.exec(headers)?.[1]); + const bodyStart = headerEnd + 4; + return buffer.length >= bodyStart + contentLength + ? buffer.subarray(bodyStart, bodyStart + contentLength) + : undefined; + }); + return body.toString('utf8'); +} + +async function readUntil( + socket: Socket, + select: (buffer: Buffer) => Buffer | undefined, +): Promise { + return await new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + const selected = select(buffer); + if (!selected) return; + cleanup(); + resolve(selected); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const cleanup = () => { + socket.off('data', onData); + socket.off('error', onError); + }; + socket.on('data', onData); + socket.once('error', onError); + }); +} + +function hostToNetworkPort(port: number): number { + return ((port & 0xff) << 8) | ((port >>> 8) & 0xff); +} diff --git a/src/platforms/apple/core/app-install.ts b/src/platforms/apple/core/app-install.ts index 7c9d923f6..dc750000e 100644 --- a/src/platforms/apple/core/app-install.ts +++ b/src/platforms/apple/core/app-install.ts @@ -7,6 +7,7 @@ import { prepareIosInstallArtifact } from './install-artifact.ts'; import { ensureBootedSimulator } from './simulator.ts'; import { invalidateIosAppResolutionCache, resolveIosApp } from './app-resolution.ts'; import { isMissingAppErrorOutput, runSimctl } from './apps-simctl.ts'; +import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; type InstallIosAppOptions = { appIdentifierHint?: string; @@ -16,6 +17,7 @@ async function uninstallIosApp(device: DeviceInfo, app: string): Promise<{ bundl return await invalidateIosAppResolutionCache(device, async () => { const bundleId = await resolveIosApp(device, app); if (device.kind !== 'simulator') { + requireCoreDeviceInstallBackend(device); await runIosDevicectl( ['device', 'uninstall', 'app', '--device', device.id, bundleId], { action: `uninstall iOS app ${bundleId}`, deviceId: device.id }, @@ -58,6 +60,9 @@ export async function installIosApp( appName?: string; launchTarget?: string; }> { + if (device.kind !== 'simulator') { + requireCoreDeviceInstallBackend(device); + } const prepared = await prepareIosInstallArtifact({ kind: 'path', path: appPath }, options); try { await installIosInstallablePath(device, prepared.installablePath); @@ -78,6 +83,9 @@ export async function reinstallIosApp( app: string, appPath: string, ): Promise<{ bundleId: string }> { + if (device.kind !== 'simulator') { + requireCoreDeviceInstallBackend(device); + } return await invalidateIosAppResolutionCache(device, async () => { const { bundleId } = await uninstallIosApp(device, app); await installIosApp(device, appPath, { appIdentifierHint: app }); @@ -91,6 +99,7 @@ export async function installIosInstallablePath( ): Promise { await invalidateIosAppResolutionCache(device, async () => { if (device.kind !== 'simulator') { + requireCoreDeviceInstallBackend(device); await runIosDevicectl( ['device', 'install', 'app', '--device', device.id, installablePath], { @@ -108,3 +117,16 @@ export async function installIosInstallablePath( await runSimctl(device, ['install', device.id, installablePath]); }); } + +function requireCoreDeviceInstallBackend(device: DeviceInfo): void { + if (resolveIosPhysicalDeviceControl(device).backend === 'coredevice') return; + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Installing apps is unavailable on this XCTest-backed physical iOS device.', + { + deviceId: device.id, + backend: 'xctest', + hint: 'Install the app with Xcode, then open it in agent-device by bundle ID.', + }, + ); +} diff --git a/src/platforms/apple/core/app-launch.ts b/src/platforms/apple/core/app-launch.ts index 33b7a7ba0..2917a9fa2 100644 --- a/src/platforms/apple/core/app-launch.ts +++ b/src/platforms/apple/core/app-launch.ts @@ -15,7 +15,9 @@ import { resolveIosDeviceDeepLinkBundleId, } from '../../../contracts/open-target.ts'; import { IOS_APP_LAUNCH_TIMEOUT_MS, IOS_SIMULATOR_TERMINATE_TIMEOUT_MS } from './config.ts'; -import { runIosDevicectl, terminateIosDeviceApp } from './devicectl.ts'; +import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; +import { runAppleRunnerCommand } from './runner/runner-client.ts'; +import type { AppleRunnerCommandOptions } from './runner/runner-provider.ts'; import { isSimulatorLaunchFBSError, probeSimulatorLaunchContext, @@ -42,6 +44,7 @@ export async function openIosApp( launchArgs?: string[]; terminateRunningApp?: boolean; url?: string; + runnerOptions?: AppleRunnerCommandOptions; }, ): Promise { const launchConsole = options?.launchConsole?.trim(); @@ -85,7 +88,11 @@ export async function openIosApp( 'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.', ); } - await launchIosDeviceProcess(device, bundleId, { payloadUrl: explicitUrl, launchArgs }); + await launchIosDeviceProcess(device, bundleId, { + payloadUrl: explicitUrl, + launchArgs, + runnerOptions: options?.runnerOptions, + }); return; } @@ -105,7 +112,11 @@ export async function openIosApp( 'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.', ); } - await launchIosDeviceProcess(device, bundleId, { payloadUrl: deepLinkTarget, launchArgs }); + await launchIosDeviceProcess(device, bundleId, { + payloadUrl: deepLinkTarget, + launchArgs, + runnerOptions: options?.runnerOptions, + }); return; } @@ -119,7 +130,10 @@ export async function openIosApp( return; } - await launchIosDeviceProcess(device, bundleId, { launchArgs }); + await launchIosDeviceProcess(device, bundleId, { + launchArgs, + runnerOptions: options?.runnerOptions, + }); } async function openIosSimulatorUrl( @@ -145,7 +159,11 @@ export async function openIosDevice(device: DeviceInfo): Promise { await ensureBootedSimulator(device); } -export async function closeIosApp(device: DeviceInfo, app: string): Promise { +export async function closeIosApp( + device: DeviceInfo, + app: string, + runnerOptions?: AppleRunnerCommandOptions, +): Promise { if (isMacOs(device)) { await closeMacOsApp(device, app); return; @@ -170,7 +188,10 @@ export async function closeIosApp(device: DeviceInfo, app: string): Promise { - const args = ['device', 'process', 'launch', '--device', device.id, bundleId]; - if (options?.payloadUrl) { - args.push('--payload-url', options.payloadUrl); - } - if (options?.launchArgs && options.launchArgs.length > 0) { - // `devicectl` uses Swift ArgumentParser; without `--` an arg starting with - // `-` / `--` could be re-interpreted as one of devicectl's own options. - args.push('--', ...options.launchArgs); - } - await runIosDevicectl(args, { action: 'launch iOS app', deviceId: device.id }); + await resolveIosPhysicalDeviceControl(device).launchApp(device, bundleId, { + ...options, + runRunnerCommand: runAppleRunnerCommand, + }); } diff --git a/src/platforms/apple/core/app-resolution.ts b/src/platforms/apple/core/app-resolution.ts index bea7bec4b..172a93965 100644 --- a/src/platforms/apple/core/app-resolution.ts +++ b/src/platforms/apple/core/app-resolution.ts @@ -13,6 +13,7 @@ import { filterAppleAppsByBundlePrefix } from './app-filter.ts'; import { listMacApps, resolveMacOsApp } from '../os/macos/apps.ts'; import { runAppleToolCommand } from './tool-provider.ts'; import { runSimctl } from './apps-simctl.ts'; +import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; const ALIASES: Record = { settings: 'com.apple.Preferences', @@ -41,6 +42,17 @@ export async function resolveIosApp(device: DeviceInfo, app: string): Promise; + launchApp( + device: DeviceInfo, + bundleId: string, + options: IosPhysicalDeviceLaunchOptions, + ): Promise; + terminateApp( + device: DeviceInfo, + bundleId: string, + options: { + runnerOptions?: AppleRunnerCommandOptions; + runRunnerCommand: AppleRunnerCommandExecutor; + }, + ): Promise; + resolveRunnerTransport( + device: DeviceInfo, + timeoutBudgetMs?: number, + ): Promise; +}; + +const CONTROLS: Record = { + coredevice: { + backend: 'coredevice', + ensureReady: ensureCoreDeviceReady, + launchApp: launchCoreDeviceApp, + terminateApp: async (device, bundleId) => await terminateIosDeviceApp(device, bundleId), + resolveRunnerTransport: async (device, timeoutBudgetMs) => ({ + kind: 'network', + tunnelIp: await resolveCoreDeviceTunnelIp(device, timeoutBudgetMs), + }), + }, + xctest: { + backend: 'xctest', + ensureReady: ensureXctestDeviceReady, + launchApp: launchXctestDeviceApp, + terminateApp: terminateXctestDeviceApp, + resolveRunnerTransport: async () => ({ kind: 'usbmux' }), + }, +}; + +export function resolveIosPhysicalDeviceControl(device: DeviceInfo): IosPhysicalDeviceControl { + return CONTROLS[device.iosPhysicalDeviceBackend === 'xctest' ? 'xctest' : 'coredevice']; +} + +async function ensureXctestDeviceReady(device: DeviceInfo): Promise { + const timeoutSeconds = Math.max(1, Math.ceil(IOS_DEVICE_READY_TIMEOUT_MS / 1000)); + const args = ['xcdevice', 'wait', '--both', `--timeout=${timeoutSeconds}`, device.id]; + const result = await runXcrun(args, { + allowFailure: true, + timeoutMs: IOS_DEVICE_READY_TIMEOUT_MS + IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS, + }); + if (result.exitCode === 0) return; + throw new AppError( + 'COMMAND_FAILED', + 'iOS device is not ready for XCTest automation', + execFailureDetails(result, { + cmd: 'xcrun', + args, + deviceId: device.id, + backend: 'xctest', + hint: 'Keep the device unlocked, trusted, connected, and visible in `xcrun xcdevice list`, then retry.', + }), + ); +} + +async function launchXctestDeviceApp( + device: DeviceInfo, + bundleId: string, + options: IosPhysicalDeviceLaunchOptions, +): Promise { + if (options.payloadUrl || (options.launchArgs && options.launchArgs.length > 0)) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'XCTest-backed physical iOS devices do not support deep links or launch arguments during open.', + { + deviceId: device.id, + backend: 'xctest', + hint: 'Open the installed app by bundle ID without a URL or --launch-args.', + }, + ); + } + await options.runRunnerCommand( + device, + { command: 'activate', appBundleId: bundleId }, + options.runnerOptions ?? {}, + ); +} + +async function terminateXctestDeviceApp( + device: DeviceInfo, + bundleId: string, + options: { + runnerOptions?: AppleRunnerCommandOptions; + runRunnerCommand: AppleRunnerCommandExecutor; + }, +): Promise { + await options.runRunnerCommand( + device, + { command: 'terminate', appBundleId: bundleId }, + options.runnerOptions ?? {}, + ); +} diff --git a/src/platforms/apple/core/physical-device-coredevice.ts b/src/platforms/apple/core/physical-device-coredevice.ts new file mode 100644 index 000000000..6784c57f3 --- /dev/null +++ b/src/platforms/apple/core/physical-device-coredevice.ts @@ -0,0 +1,238 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { DeviceInfo } from '../../../kernel/device.ts'; +import { AppError } from '../../../kernel/errors.ts'; +import { execFailureDetails } from '../../../utils/exec.ts'; +import { + IOS_DEVICECTL_DEFAULT_HINT, + resolveIosDevicectlHint, + runIosDevicectl, +} from './devicectl.ts'; +import { + IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS, + IOS_DEVICE_READY_TIMEOUT_MS, +} from './physical-device-constants.ts'; +import { runXcrun } from './tool-provider.ts'; + +const IOS_RUNNER_DEVICE_INFO_TIMEOUT_MS = 10_000; + +export async function launchCoreDeviceApp( + device: DeviceInfo, + bundleId: string, + options: { payloadUrl?: string; launchArgs?: string[] } = {}, +): Promise { + const args = ['device', 'process', 'launch', '--device', device.id, bundleId]; + if (options.payloadUrl) { + args.push('--payload-url', options.payloadUrl); + } + if (options.launchArgs && options.launchArgs.length > 0) { + // `devicectl` uses Swift ArgumentParser; preserve app-owned leading dashes. + args.push('--', ...options.launchArgs); + } + await runIosDevicectl(args, { action: 'launch iOS app', deviceId: device.id }); +} + +export async function ensureCoreDeviceReady(device: DeviceInfo): Promise { + try { + const probe = await runCoreDeviceDetails( + device.id, + IOS_DEVICE_READY_TIMEOUT_MS, + IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS, + ); + const { result, parsed } = probe; + if (result.exitCode === 0) { + if (!parsed.parsed) { + throw new AppError('COMMAND_FAILED', 'iOS device readiness probe failed', { + kind: 'probe_inconclusive', + deviceId: device.id, + stdout: result.stdout, + stderr: result.stderr, + hint: 'CoreDevice returned success but readiness JSON output was missing or invalid. Retry; if it persists restart Xcode and the iOS device.', + }); + } + const tunnelState = parsed.tunnelState?.toLowerCase(); + if (tunnelState === 'connecting') { + throw new AppError('COMMAND_FAILED', 'iOS device is not ready for automation', { + kind: 'not_ready', + deviceId: device.id, + tunnelState, + hint: 'Device tunnel is still connecting. Keep the device unlocked and connected by cable until it is fully available in Xcode Devices, then retry.', + }); + } + return; + } + throw new AppError( + 'COMMAND_FAILED', + 'iOS device is not ready for automation', + execFailureDetails(result, { + kind: 'not_ready', + deviceId: device.id, + tunnelState: parsed.tunnelState, + hint: resolveIosReadyHint(result.stdout, result.stderr), + }), + ); + } catch (error) { + throw normalizeCoreDeviceReadyError(device.id, error); + } +} + +function normalizeCoreDeviceReadyError(deviceId: string, error: unknown): AppError { + if (!(error instanceof AppError) || error.code !== 'COMMAND_FAILED') { + return buildUnexpectedCoreDeviceReadyError(deviceId, error); + } + const kind = typeof error.details?.kind === 'string' ? error.details.kind : ''; + if (kind === 'not_ready') return error; + return normalizeCoreDeviceProbeError(deviceId, error); +} + +function normalizeCoreDeviceProbeError(deviceId: string, error: AppError): AppError { + const details = (error.details ?? {}) as { + stdout?: string; + stderr?: string; + timeoutMs?: number; + }; + const stdout = String(details.stdout ?? ''); + const stderr = String(details.stderr ?? ''); + const timeoutMs = Number(details.timeoutMs ?? IOS_DEVICE_READY_TIMEOUT_MS); + const timeoutHint = `CoreDevice did not respond within ${timeoutMs}ms. Keep the device unlocked and trusted, then retry; if it persists restart Xcode and the iOS device.`; + return new AppError( + 'COMMAND_FAILED', + 'iOS device readiness probe failed', + { + deviceId, + cause: error.message, + timeoutMs, + stdout, + stderr, + hint: stdout || stderr ? resolveIosReadyHint(stdout, stderr) : timeoutHint, + }, + error, + ); +} + +function buildUnexpectedCoreDeviceReadyError(deviceId: string, error: unknown): AppError { + return new AppError( + 'COMMAND_FAILED', + 'iOS device readiness probe failed', + { + deviceId, + hint: 'Reconnect the device, keep it unlocked, and retry.', + }, + error instanceof Error ? error : undefined, + ); +} + +export async function resolveCoreDeviceTunnelIp( + device: DeviceInfo, + timeoutBudgetMs?: number, +): Promise { + if (typeof timeoutBudgetMs === 'number' && timeoutBudgetMs <= 0) return null; + const timeoutMs = + typeof timeoutBudgetMs === 'number' + ? Math.max(1, Math.min(IOS_RUNNER_DEVICE_INFO_TIMEOUT_MS, timeoutBudgetMs)) + : IOS_RUNNER_DEVICE_INFO_TIMEOUT_MS; + try { + const probe = await runCoreDeviceDetails(device.id, timeoutMs); + if (probe.result.exitCode !== 0 || !probe.parsed.parsed) return null; + if (probe.parsed.outcome && probe.parsed.outcome !== 'success') return null; + return probe.parsed.tunnelIp ?? null; + } catch { + return null; + } +} + +async function runCoreDeviceDetails( + deviceId: string, + timeoutMs: number, + commandTimeoutBufferMs = 0, +): Promise<{ + result: Awaited>; + parsed: { parsed: boolean; outcome?: string; tunnelState?: string; tunnelIp?: string }; +}> { + const jsonPath = path.join( + os.tmpdir(), + `agent-device-coredevice-info-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); + const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000)); + try { + const result = await runXcrun( + [ + 'devicectl', + 'device', + 'info', + 'details', + '--device', + deviceId, + '--json-output', + jsonPath, + '--timeout', + String(timeoutSeconds), + ], + { + allowFailure: true, + timeoutMs: timeoutMs + commandTimeoutBufferMs, + }, + ); + return { result, parsed: await readCoreDeviceDetails(jsonPath) }; + } finally { + await fs.rm(jsonPath, { force: true }).catch(() => {}); + } +} + +async function readCoreDeviceDetails( + jsonPath: string, +): Promise<{ parsed: boolean; outcome?: string; tunnelState?: string; tunnelIp?: string }> { + try { + const payload = JSON.parse(await fs.readFile(jsonPath, 'utf8')) as unknown; + const details = parseIosDeviceDetailsPayload(payload); + return { parsed: true, ...details }; + } catch { + return { parsed: false }; + } +} + +export function parseIosDeviceDetailsPayload(payload: unknown): { + outcome?: string; + tunnelState?: string; + tunnelIp?: string; +} { + const result = (payload as { result?: unknown } | null | undefined)?.result; + if (!result || typeof result !== 'object') return {}; + const direct = ( + result as { + connectionProperties?: { tunnelState?: unknown; tunnelIPAddress?: unknown }; + } + ).connectionProperties; + const nested = ( + result as { + device?: { connectionProperties?: { tunnelState?: unknown; tunnelIPAddress?: unknown } }; + } + ).device?.connectionProperties; + const tunnelState = + readNonEmptyString(direct?.tunnelState) ?? readNonEmptyString(nested?.tunnelState); + const tunnelIp = + readNonEmptyString(direct?.tunnelIPAddress) ?? readNonEmptyString(nested?.tunnelIPAddress); + const outcome = readNonEmptyString( + (payload as { info?: { outcome?: unknown } } | null | undefined)?.info?.outcome, + ); + return { + ...(outcome ? { outcome } : {}), + ...(tunnelState ? { tunnelState } : {}), + ...(tunnelIp ? { tunnelIp } : {}), + }; +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +export function resolveIosReadyHint(stdout: string, stderr: string): string { + const devicectlHint = resolveIosDevicectlHint(stdout, stderr); + if (devicectlHint) return devicectlHint; + const text = `${stdout}\n${stderr}`.toLowerCase(); + if (text.includes('timed out waiting for all destinations')) { + return 'Xcode destination did not become available in time. Keep device unlocked and retry.'; + } + return IOS_DEVICECTL_DEFAULT_HINT; +} diff --git a/src/platforms/apple/core/runner/runner-command-manifest.ts b/src/platforms/apple/core/runner/runner-command-manifest.ts index 0525b6315..417213eec 100644 --- a/src/platforms/apple/core/runner/runner-command-manifest.ts +++ b/src/platforms/apple/core/runner/runner-command-manifest.ts @@ -38,6 +38,8 @@ export const RUNNER_COMMAND_TRAIT_MANIFEST = { recordStop: 'default', status: 'readOnlyReadinessProbe', uptime: 'readOnlyReadinessProbe', + activate: 'readinessPreflightExemptMutation', + terminate: 'readinessPreflightExemptMutation', targetReset: 'readinessPreflightExemptMutation', shutdown: 'default', } as const satisfies Record; diff --git a/src/platforms/apple/core/runner/runner-command-route.ts b/src/platforms/apple/core/runner/runner-command-route.ts new file mode 100644 index 000000000..d850e440b --- /dev/null +++ b/src/platforms/apple/core/runner/runner-command-route.ts @@ -0,0 +1,106 @@ +import type { DeviceInfo } from '../../../../kernel/device.ts'; +import { resolveIosPhysicalDeviceControl } from '../physical-device-control.ts'; + +const RUNNER_DEVICE_TUNNEL_IP_CACHE_TTL_MS = 30_000; + +type DeviceTunnelIpCacheEntry = { + ip: string; + expiresAt: number; +}; + +export type RunnerCommandRoute = + | { + kind: 'network'; + endpoints: string[]; + cachedTunnelIp: boolean; + } + | { + kind: 'usbmux'; + endpoints: [string]; + cachedTunnelIp: false; + }; + +const deviceTunnelIpCache = new Map(); + +export function createRunnerCommandRouteResolver(device: DeviceInfo, port: number) { + let requestTunnelIp: string | null | undefined; + return { + resolveRoute: async ( + timeoutBudgetMs?: number, + forceRefresh = false, + ): Promise => { + if (device.kind !== 'device') { + return buildNetworkRoute(device, port, null, false); + } + const control = resolveIosPhysicalDeviceControl(device); + if (control.backend === 'xctest') { + const transport = await control.resolveRunnerTransport(device, timeoutBudgetMs); + return { + kind: transport.kind, + endpoints: [`usbmux://${device.id}:${port}/command`], + cachedTunnelIp: false, + }; + } + if (!forceRefresh) { + const cached = readDeviceTunnelIpCache(device.id); + if (cached) return buildNetworkRoute(device, port, cached, true); + if (requestTunnelIp !== undefined) { + return buildNetworkRoute(device, port, requestTunnelIp, false); + } + } + const transport = await control.resolveRunnerTransport(device, timeoutBudgetMs); + if (transport.kind === 'usbmux') { + return { + kind: 'usbmux', + endpoints: [`usbmux://${device.id}:${port}/command`], + cachedTunnelIp: false, + }; + } + const tunnelIp = transport.tunnelIp; + requestTunnelIp = tunnelIp; + if (tunnelIp) writeDeviceTunnelIpCache(device.id, tunnelIp); + return buildNetworkRoute(device, port, tunnelIp, false); + }, + }; +} + +export function invalidateDeviceTunnelIpCache(deviceId: string): void { + deviceTunnelIpCache.delete(deviceId); +} + +/** + * @internal Test isolation hook for the process-global device tunnel IP cache. + */ +export function clearDeviceTunnelIpCache(): void { + deviceTunnelIpCache.clear(); +} + +function buildNetworkRoute( + device: DeviceInfo, + port: number, + tunnelIp: string | null, + cachedTunnelIp: boolean, +): RunnerCommandRoute { + const endpoints = [`http://127.0.0.1:${port}/command`]; + if (device.kind === 'device' && tunnelIp) { + endpoints.unshift(`http://[${tunnelIp}]:${port}/command`); + } + return { kind: 'network', endpoints, cachedTunnelIp }; +} + +function readDeviceTunnelIpCache(deviceId: string): string | null { + const cached = deviceTunnelIpCache.get(deviceId); + if (!cached) return null; + if (cached.expiresAt <= Date.now()) { + deviceTunnelIpCache.delete(deviceId); + return null; + } + return cached.ip; +} + +function writeDeviceTunnelIpCache(deviceId: string, ip: string): void { + deviceTunnelIpCache.set(deviceId, { + ip, + expiresAt: Date.now() + RUNNER_DEVICE_TUNNEL_IP_CACHE_TTL_MS, + }); +} diff --git a/src/platforms/apple/core/runner/runner-contract.ts b/src/platforms/apple/core/runner/runner-contract.ts index 756aadde3..4eb3d97e7 100644 --- a/src/platforms/apple/core/runner/runner-contract.ts +++ b/src/platforms/apple/core/runner/runner-contract.ts @@ -49,6 +49,8 @@ export type RunnerCommand = { | 'recordStop' | 'status' | 'uptime' + | 'activate' + | 'terminate' | 'targetReset' | 'shutdown'; commandId?: string; @@ -84,6 +86,7 @@ export type RunnerCommand = { scope?: string; raw?: boolean; fullscreen?: boolean; + inlineScreenshot?: boolean; synthesized?: boolean; steps?: RunnerSequenceStep[]; }; diff --git a/src/platforms/apple/core/runner/runner-io.ts b/src/platforms/apple/core/runner/runner-io.ts new file mode 100644 index 000000000..1c1094a47 --- /dev/null +++ b/src/platforms/apple/core/runner/runner-io.ts @@ -0,0 +1,58 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import { AppError } from '../../../../kernel/errors.ts'; + +export async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (typeof address === 'object' && address?.port) { + const port = address.port; + server.close(() => resolve(port)); + } else { + server.close(() => reject(new AppError('COMMAND_FAILED', 'Failed to allocate port'))); + } + }); + server.on('error', reject); + }); +} + +export function logChunk( + chunk: string, + logPath?: string, + traceLogPath?: string, + verbose?: boolean, +): void { + if (logPath) appendLogChunk(logPath, chunk); + if (traceLogPath) appendLogChunk(traceLogPath, chunk); + if (verbose) { + process.stderr.write(chunk); + } +} + +const logAppendQueues = new Map>(); + +function appendLogChunk(logPath: string, chunk: string): void { + const previous = logAppendQueues.get(logPath) ?? Promise.resolve(); + const next = previous + .catch(() => {}) + .then(async () => { + await fs.promises.mkdir(path.dirname(logPath), { recursive: true }); + await fs.promises.appendFile(logPath, chunk); + }) + .catch(() => {}); + const queued = next.finally(() => { + if (logAppendQueues.get(logPath) === queued) { + logAppendQueues.delete(logPath); + } + }); + logAppendQueues.set(logPath, queued); +} + +export function cleanupTempFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } catch {} +} diff --git a/src/platforms/apple/core/runner/runner-transport.ts b/src/platforms/apple/core/runner/runner-transport.ts index 8f1a0f529..a413d5c9c 100644 --- a/src/platforms/apple/core/runner/runner-transport.ts +++ b/src/platforms/apple/core/runner/runner-transport.ts @@ -1,7 +1,3 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import net from 'node:net'; import { createRequestCanceledError, isRequestCanceledError } from '../../../../request/cancel.ts'; import { AppError } from '../../../../kernel/errors.ts'; import { requireExecSuccess } from '../../../../utils/exec.ts'; @@ -10,6 +6,11 @@ import type { DeviceInfo } from '../../../../kernel/device.ts'; import { classifyBootFailure, bootFailureHint } from '../../../boot-diagnostics.ts'; import { buildSimctlArgsForDevice } from '../simctl.ts'; import { runXcrun } from '../tool-provider.ts'; +import { + createRunnerCommandRouteResolver, + invalidateDeviceTunnelIpCache, + type RunnerCommandRoute, +} from './runner-command-route.ts'; import { buildRunnerConnectError, buildRunnerEarlyExitError, @@ -17,6 +18,9 @@ import { type RunnerCommand, } from './runner-contract.ts'; import type { RunnerSession } from './runner-session-types.ts'; +import { usbmuxRunnerTransport } from './runner-usbmux.ts'; + +export { cleanupTempFile, getFreePort, logChunk } from './runner-io.ts'; export const RUNNER_STARTUP_TIMEOUT_MS = 45_000; export const RUNNER_COMMAND_TIMEOUT_MS = 45_000; @@ -24,17 +28,8 @@ const RUNNER_CONNECT_ATTEMPT_INTERVAL_MS = 250; const RUNNER_CONNECT_RETRY_BASE_DELAY_MS = 300; const RUNNER_CONNECT_RETRY_MAX_DELAY_MS = 2_000; const RUNNER_CONNECT_REQUEST_TIMEOUT_MS = 20_000; -const RUNNER_DEVICE_INFO_TIMEOUT_MS = 10_000; -const RUNNER_DEVICE_TUNNEL_IP_CACHE_TTL_MS = 30_000; export const RUNNER_DESTINATION_TIMEOUT_SECONDS = 20; -type DeviceTunnelIpCacheEntry = { - ip: string; - expiresAt: number; -}; - -const deviceTunnelIpCache = new Map(); - export async function waitForRunner( device: DeviceInfo, port: number, @@ -45,8 +40,8 @@ export async function waitForRunner( signal?: AbortSignal, ): Promise { const deadline = Deadline.fromTimeoutMs(timeoutMs); - const { getEndpoints } = createRunnerEndpointResolver(device, port); - let { endpoints } = await getEndpoints(deadline.remainingMs()); + const { resolveRoute } = createRunnerCommandRouteResolver(device, port); + let route = await resolveRoute(deadline.remainingMs()); let lastError: unknown = null; const maxAttempts = Math.max(1, Math.ceil(timeoutMs / RUNNER_CONNECT_ATTEMPT_INTERVAL_MS)); try { @@ -59,19 +54,24 @@ export async function waitForRunner( timeoutMs, logPath, session, - endpoints, - getEndpoints, + route, + resolveRoute, signal, attemptDeadline, - setEndpoints: (nextEndpoints) => { - endpoints = nextEndpoints; + setRoute: (nextRoute) => { + route = nextRoute; }, setLastError: (err) => { lastError = err; }, }); if (response) return response; - throw buildRunnerEndpointProbeError({ port, endpoints, lastError, signal }); + throw buildRunnerEndpointProbeError({ + port, + endpoints: route.endpoints, + lastError, + signal, + }); }, { maxAttempts, @@ -98,16 +98,16 @@ export async function waitForRunner( if (device.kind === 'simulator') { const remainingMs = deadline.remainingMs(); if (remainingMs <= 0) { - throw buildRunnerConnectError({ port, endpoints, logPath, lastError }); + throw buildRunnerConnectError({ port, endpoints: route.endpoints, logPath, lastError }); } const simResponse = await postCommandViaSimulator(device, port, command, remainingMs, signal); return new Response(simResponse.body, { status: simResponse.status }); } - throw buildRunnerConnectError({ port, endpoints, logPath, lastError }); + throw buildRunnerConnectError({ port, endpoints: route.endpoints, logPath, lastError }); } -type RunnerEndpointResolver = ReturnType['getEndpoints']; +type RunnerRouteResolver = ReturnType['resolveRoute']; async function attemptRunnerConnection(params: { device: DeviceInfo; @@ -116,16 +116,16 @@ async function attemptRunnerConnection(params: { timeoutMs: number; logPath?: string; session?: RunnerSession; - endpoints: string[]; - getEndpoints: RunnerEndpointResolver; + route: RunnerCommandRoute; + resolveRoute: RunnerRouteResolver; signal?: AbortSignal; attemptDeadline?: Deadline; - setEndpoints: (endpoints: string[]) => void; + setRoute: (route: RunnerCommandRoute) => void; setLastError: (error: unknown) => void; }): Promise { await ensureRunnerAttemptCanStart(params); - const primary = await tryPrimaryRunnerEndpoints(params); + const primary = await tryPrimaryRunnerRoute(params); if (primary.response) return primary.response; const simulatorFallback = await tryReadySimulatorEndpoint(params); @@ -156,29 +156,28 @@ async function ensureRunnerAttemptCanStart(params: { } } -async function tryPrimaryRunnerEndpoints(params: { +async function tryPrimaryRunnerRoute(params: { device: DeviceInfo; port: number; command: RunnerCommand; timeoutMs: number; - endpoints: string[]; - getEndpoints: RunnerEndpointResolver; + route: RunnerCommandRoute; + resolveRoute: RunnerRouteResolver; signal?: AbortSignal; attemptDeadline?: Deadline; - setEndpoints: (endpoints: string[]) => void; + setRoute: (route: RunnerCommandRoute) => void; setLastError: (error: unknown) => void; }): Promise<{ response: Response | null; usedCachedTunnelIp: boolean }> { - let endpoints = params.endpoints; + let route = params.route; let usedCachedTunnelIp = false; if (params.device.kind === 'device') { - const resolved = await params.getEndpoints(params.attemptDeadline?.remainingMs()); - endpoints = resolved.endpoints; - usedCachedTunnelIp = resolved.cached; - params.setEndpoints(endpoints); + route = await params.resolveRoute(params.attemptDeadline?.remainingMs()); + usedCachedTunnelIp = route.cachedTunnelIp; + params.setRoute(route); } - const cachedTunnelEndpoint = usedCachedTunnelIp ? endpoints[0] : null; - const response = await tryRunnerEndpoints(endpoints, { + const cachedTunnelEndpoint = usedCachedTunnelIp ? route.endpoints[0] : null; + const response = await tryRunnerRoute(params.device, route, { command: params.command, port: params.port, timeoutMs: params.timeoutMs, @@ -217,19 +216,19 @@ async function tryRefreshedDeviceTunnel( port: number; command: RunnerCommand; timeoutMs: number; - getEndpoints: RunnerEndpointResolver; + resolveRoute: RunnerRouteResolver; signal?: AbortSignal; attemptDeadline?: Deadline; - setEndpoints: (endpoints: string[]) => void; + setRoute: (route: RunnerCommandRoute) => void; setLastError: (error: unknown) => void; }, usedCachedTunnelIp: boolean, ): Promise { if (params.device.kind !== 'device' || !usedCachedTunnelIp) return null; invalidateDeviceTunnelIpCache(params.device.id); - const refreshed = await params.getEndpoints(params.attemptDeadline?.remainingMs(), true); - params.setEndpoints(refreshed.endpoints); - return await tryRunnerEndpoints(refreshed.endpoints, { + const refreshed = await params.resolveRoute(params.attemptDeadline?.remainingMs(), true); + params.setRoute(refreshed); + return await tryRunnerRoute(params.device, refreshed, { command: params.command, port: params.port, timeoutMs: params.timeoutMs, @@ -268,19 +267,22 @@ export async function sendRunnerCommandOnce( throw createRequestCanceledError(); } const deadline = Deadline.fromTimeoutMs(timeoutMs); - const { getEndpoints } = createRunnerEndpointResolver(device, port); - const { endpoints } = await getEndpoints(deadline.remainingMs()); - const endpoint = endpoints[0]; + const { resolveRoute } = createRunnerCommandRouteResolver(device, port); + const route = await resolveRoute(deadline.remainingMs()); + const remainingMs = deadline.remainingMs(); + if (remainingMs <= 0) { + throw new AppError('COMMAND_FAILED', 'Runner command deadline exceeded', { timeoutMs }); + } + if (route.kind === 'usbmux') { + return await usbmuxRunnerTransport.postCommand(device.id, port, command, remainingMs, signal); + } + const endpoint = route.endpoints[0]; if (!endpoint) { throw new AppError('COMMAND_FAILED', 'Runner command endpoint not available', { port, - endpoints, + endpoints: route.endpoints, }); } - const remainingMs = deadline.remainingMs(); - if (remainingMs <= 0) { - throw new AppError('COMMAND_FAILED', 'Runner command deadline exceeded', { timeoutMs }); - } return await fetchWithTimeout( endpoint, { @@ -293,25 +295,44 @@ export async function sendRunnerCommandOnce( ); } -function createRunnerEndpointResolver(device: DeviceInfo, port: number) { - let requestTunnelIp: string | null | undefined; - return { - getEndpoints: async (timeoutBudgetMs?: number, forceRefresh = false) => { - const tunnelIp = await getDeviceTunnelIpForRequest({ - device, - timeoutBudgetMs, - forceRefresh, - requestTunnelIp, - setRequestTunnelIp: (ip) => { - requestTunnelIp = ip; - }, +async function tryRunnerRoute( + device: DeviceInfo, + route: RunnerCommandRoute, + params: { + command: RunnerCommand; + port: number; + timeoutMs: number; + signal?: AbortSignal; + attemptDeadline?: Deadline; + onError: (endpoint: string, error: unknown) => void; + }, +): Promise { + if (route.kind === 'network') { + return await tryRunnerEndpoints(route.endpoints, params); + } + const endpoint = route.endpoints[0]; + try { + const remainingMs = params.attemptDeadline?.remainingMs() ?? params.timeoutMs; + if (remainingMs <= 0) { + throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', { + port: params.port, + timeoutMs: params.timeoutMs, }); - return { - endpoints: resolveRunnerCommandEndpoints(device, port, tunnelIp.ip), - cached: tunnelIp.sharedCacheHit, - }; - }, - }; + } + return await usbmuxRunnerTransport.postCommand( + device.id, + params.port, + params.command, + Math.min(RUNNER_CONNECT_REQUEST_TIMEOUT_MS, remainingMs), + params.signal, + ); + } catch (error) { + if (params.signal?.aborted || isRequestCanceledError(error)) { + throw createRequestCanceledError(); + } + params.onError(endpoint, error); + return null; + } } async function tryRunnerEndpoints( @@ -380,71 +401,6 @@ async function tryRunnerSimulatorEndpoint( } } -async function getDeviceTunnelIpForRequest(params: { - device: DeviceInfo; - timeoutBudgetMs?: number; - forceRefresh: boolean; - requestTunnelIp: string | null | undefined; - setRequestTunnelIp: (ip: string | null) => void; -}): Promise<{ ip: string | null; sharedCacheHit: boolean }> { - const { device, timeoutBudgetMs, forceRefresh, requestTunnelIp, setRequestTunnelIp } = params; - if (device.kind !== 'device') { - return { ip: null, sharedCacheHit: false }; - } - if (!forceRefresh) { - const cached = readDeviceTunnelIpCache(device.id); - if (cached) return { ip: cached, sharedCacheHit: true }; - if (requestTunnelIp !== undefined) return { ip: requestTunnelIp, sharedCacheHit: false }; - } - const ip = await resolveDeviceTunnelIp(device.id, timeoutBudgetMs); - setRequestTunnelIp(ip); - if (ip) writeDeviceTunnelIpCache(device.id, ip); - return { ip, sharedCacheHit: false }; -} - -function readDeviceTunnelIpCache(deviceId: string): string | null { - const cached = deviceTunnelIpCache.get(deviceId); - if (!cached) return null; - if (cached.expiresAt <= Date.now()) { - deviceTunnelIpCache.delete(deviceId); - return null; - } - return cached.ip; -} - -function writeDeviceTunnelIpCache(deviceId: string, ip: string): void { - deviceTunnelIpCache.set(deviceId, { - ip, - expiresAt: Date.now() + RUNNER_DEVICE_TUNNEL_IP_CACHE_TTL_MS, - }); -} - -function invalidateDeviceTunnelIpCache(deviceId: string): void { - deviceTunnelIpCache.delete(deviceId); -} - -/** - * @internal Test isolation hook for the process-global device tunnel IP cache. - */ -export function clearDeviceTunnelIpCache(): void { - deviceTunnelIpCache.clear(); -} - -function resolveRunnerCommandEndpoints( - device: DeviceInfo, - port: number, - tunnelIp: string | null, -): string[] { - const endpoints = [`http://127.0.0.1:${port}/command`]; - if (device.kind !== 'device') { - return endpoints; - } - if (tunnelIp) { - endpoints.unshift(`http://[${tunnelIp}]:${port}/command`); - } - return endpoints; -} - async function fetchWithTimeout( url: string, init: RequestInit, @@ -456,63 +412,6 @@ async function fetchWithTimeout( return await fetch(url, { ...init, signal }); } -async function resolveDeviceTunnelIp( - deviceId: string, - timeoutBudgetMs?: number, -): Promise { - if (typeof timeoutBudgetMs === 'number' && timeoutBudgetMs <= 0) { - return null; - } - const timeoutMs = - typeof timeoutBudgetMs === 'number' - ? Math.max(1, Math.min(RUNNER_DEVICE_INFO_TIMEOUT_MS, timeoutBudgetMs)) - : RUNNER_DEVICE_INFO_TIMEOUT_MS; - const jsonPath = path.join( - os.tmpdir(), - `agent-device-devicectl-info-${process.pid}-${Date.now()}.json`, - ); - try { - const devicectlTimeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000)); - const result = await runXcrun( - [ - 'devicectl', - 'device', - 'info', - 'details', - '--device', - deviceId, - '--json-output', - jsonPath, - '--timeout', - String(devicectlTimeoutSeconds), - ], - { allowFailure: true, timeoutMs }, - ); - if (result.exitCode !== 0 || !fs.existsSync(jsonPath)) { - return null; - } - const payload = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) as { - info?: { outcome?: string }; - result?: { - connectionProperties?: { tunnelIPAddress?: string }; - device?: { connectionProperties?: { tunnelIPAddress?: string } }; - }; - }; - if (payload.info?.outcome && payload.info.outcome !== 'success') { - return null; - } - const ip = ( - payload.result?.connectionProperties?.tunnelIPAddress ?? - payload.result?.device?.connectionProperties?.tunnelIPAddress - )?.trim(); - return ip && ip.length > 0 ? ip : null; - } catch { - return null; - } finally { - cleanupTempFile(jsonPath); - } -} - async function postCommandViaSimulator( device: DeviceInfo, port: number, @@ -554,57 +453,3 @@ async function postCommandViaSimulator( const body = result.stdout as string; return { status: 200, body }; } - -export async function getFreePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (typeof address === 'object' && address?.port) { - const port = address.port; - server.close(() => resolve(port)); - } else { - server.close(() => reject(new AppError('COMMAND_FAILED', 'Failed to allocate port'))); - } - }); - server.on('error', reject); - }); -} - -export function logChunk( - chunk: string, - logPath?: string, - traceLogPath?: string, - verbose?: boolean, -): void { - if (logPath) appendLogChunk(logPath, chunk); - if (traceLogPath) appendLogChunk(traceLogPath, chunk); - if (verbose) { - process.stderr.write(chunk); - } -} - -const logAppendQueues = new Map>(); - -function appendLogChunk(logPath: string, chunk: string): void { - const previous = logAppendQueues.get(logPath) ?? Promise.resolve(); - const next = previous - .catch(() => {}) - .then(async () => { - await fs.promises.mkdir(path.dirname(logPath), { recursive: true }); - await fs.promises.appendFile(logPath, chunk); - }) - .catch(() => {}); - const queued = next.finally(() => { - if (logAppendQueues.get(logPath) === queued) { - logAppendQueues.delete(logPath); - } - }); - logAppendQueues.set(logPath, queued); -} - -export function cleanupTempFile(filePath: string): void { - try { - if (fs.existsSync(filePath)) fs.unlinkSync(filePath); - } catch {} -} diff --git a/src/platforms/apple/core/runner/runner-usbmux-protocol.ts b/src/platforms/apple/core/runner/runner-usbmux-protocol.ts new file mode 100644 index 000000000..e938bd461 --- /dev/null +++ b/src/platforms/apple/core/runner/runner-usbmux-protocol.ts @@ -0,0 +1,278 @@ +import net, { type Socket } from 'node:net'; +import { AppError } from '../../../../kernel/errors.ts'; +import { createRequestCanceledError } from '../../../../request/cancel.ts'; +import { Deadline } from '../../../../utils/retry.ts'; +import { parseXmlDocumentSync, type XmlNode } from '../../../../utils/xml.ts'; + +const USBMUX_HEADER_BYTES = 16; +const USBMUX_PROTOCOL_VERSION = 1; +const USBMUX_MESSAGE_PLIST = 8; +const USBMUX_MAX_PACKET_BYTES = 4 * 1024 * 1024; + +export async function openUsbmuxRunnerSocket( + socketPath: string, + udid: string, + port: number, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + const deadline = Deadline.fromTimeoutMs(timeoutMs); + const deviceId = await resolveUsbmuxDeviceId(socketPath, udid, deadline.remainingMs(), signal); + return await openUsbmuxDeviceSocket(socketPath, deviceId, port, deadline.remainingMs(), signal); +} + +async function resolveUsbmuxDeviceId( + socketPath: string, + udid: string, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + const socket = await connectUsbmuxd(socketPath, timeoutMs, signal); + try { + await writePlistPacket(socket, 1, buildPlistMessage('ListDevices')); + const packet = await readUsbmuxPacket(socket, timeoutMs, signal); + const deviceId = readDeviceIdFromList(packet.payload.toString('utf8'), udid); + if (deviceId !== undefined) return deviceId; + throw new AppError('DEVICE_NOT_FOUND', 'iOS device is not available through usbmux', { + deviceId: udid, + backend: 'xctest', + hint: 'Connect the device by cable, trust this Mac, keep it unlocked, and retry.', + }); + } finally { + socket.destroy(); + } +} + +async function openUsbmuxDeviceSocket( + socketPath: string, + deviceId: number, + port: number, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + const socket = await connectUsbmuxd(socketPath, timeoutMs, signal); + try { + await writePlistPacket( + socket, + 2, + buildPlistMessage('Connect', { + DeviceID: deviceId, + PortNumber: hostToNetworkPort(port), + }), + ); + const packet = await readUsbmuxPacket(socket, timeoutMs, signal); + const result = readPlistInteger(packet.payload.toString('utf8'), 'Number'); + if (result !== 0) { + throw new AppError('COMMAND_FAILED', 'Failed to connect to XCTest runner through usbmux', { + backend: 'xctest', + deviceId, + port, + usbmuxResult: result, + hint: 'Keep the device connected by cable and unlocked, then retry.', + }); + } + return socket; + } catch (error) { + socket.destroy(); + throw error; + } +} + +async function connectUsbmuxd( + socketPath: string, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + requireTimeRemaining(timeoutMs, 'connect to usbmuxd'); + if (signal?.aborted) throw createRequestCanceledError(); + return await new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + let settled = false; + const timer = setTimeout(() => { + finish( + new AppError('COMMAND_FAILED', 'Timed out connecting to usbmuxd', { + backend: 'xctest', + socketPath, + timeoutMs, + }), + ); + }, timeoutMs); + const onConnect = () => finish(); + const onError = (error: Error) => finish(error); + const onAbort = () => finish(createRequestCanceledError()); + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.off('connect', onConnect); + socket.off('error', onError); + signal?.removeEventListener('abort', onAbort); + if (error) { + socket.destroy(); + reject(error); + } else { + resolve(socket); + } + }; + socket.once('connect', onConnect); + socket.once('error', onError); + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +async function writePlistPacket(socket: Socket, tag: number, payload: string): Promise { + const body = Buffer.from(payload, 'utf8'); + const packet = Buffer.alloc(USBMUX_HEADER_BYTES + body.length); + packet.writeUInt32LE(packet.length, 0); + packet.writeUInt32LE(USBMUX_PROTOCOL_VERSION, 4); + packet.writeUInt32LE(USBMUX_MESSAGE_PLIST, 8); + packet.writeUInt32LE(tag, 12); + body.copy(packet, USBMUX_HEADER_BYTES); + if (socket.write(packet)) return; + await new Promise((resolve, reject) => { + socket.once('drain', resolve); + socket.once('error', reject); + }); +} + +async function readUsbmuxPacket( + socket: Socket, + timeoutMs: number, + signal?: AbortSignal, +): Promise<{ payload: Buffer }> { + requireTimeRemaining(timeoutMs, 'read usbmuxd response'); + if (signal?.aborted) throw createRequestCanceledError(); + return await new Promise<{ payload: Buffer }>((resolve, reject) => { + let buffer = Buffer.alloc(0); + let settled = false; + const timer = setTimeout( + () => + finish( + new AppError('COMMAND_FAILED', 'Timed out reading usbmuxd response', { + backend: 'xctest', + timeoutMs, + }), + ), + timeoutMs, + ); + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + if (buffer.length < USBMUX_HEADER_BYTES) return; + const packetBytes = buffer.readUInt32LE(0); + if (packetBytes < USBMUX_HEADER_BYTES || packetBytes > USBMUX_MAX_PACKET_BYTES) { + finish(new AppError('COMMAND_FAILED', 'Invalid usbmuxd response length')); + return; + } + if (buffer.length < packetBytes) return; + finish(undefined, { payload: buffer.subarray(USBMUX_HEADER_BYTES, packetBytes) }); + }; + const onError = (error: Error) => finish(error); + const onClose = () => + finish(new AppError('COMMAND_FAILED', 'usbmuxd closed the connection unexpectedly')); + const onAbort = () => finish(createRequestCanceledError()); + const finish = (error?: Error, packet?: { payload: Buffer }) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.off('data', onData); + socket.off('error', onError); + socket.off('close', onClose); + signal?.removeEventListener('abort', onAbort); + socket.pause(); + if (error) reject(error); + else resolve(packet as { payload: Buffer }); + }; + socket.on('data', onData); + socket.once('error', onError); + socket.once('close', onClose); + signal?.addEventListener('abort', onAbort, { once: true }); + socket.resume(); + }); +} + +function buildPlistMessage( + messageType: string, + fields: Record = {}, +): string { + const entries: Array<[string, string | number]> = [ + ['BundleID', 'com.callstack.agent-device'], + ['ClientVersionString', 'agent-device'], + ['MessageType', messageType], + ['ProgName', 'agent-device'], + ['kLibUSBMuxVersion', 3], + ...Object.entries(fields), + ]; + const body = entries + .map(([key, value]) => + typeof value === 'number' + ? `${escapeXml(key)}${value}` + : `${escapeXml(key)}${escapeXml(value)}`, + ) + .join(''); + return `${body}`; +} + +function readDeviceIdFromList(xml: string, udid: string): number | undefined { + for (const node of walkXmlNodes(parseXmlDocumentSync(xml))) { + const device = readListedDevice(node); + if (device?.udid === udid) return device.id; + } + return undefined; +} + +function readListedDevice(node: XmlNode): { udid: string; id: number } | undefined { + if (node.name !== 'dict') return undefined; + const properties = readDictEntry(node, 'Properties'); + if (properties?.name !== 'dict') return undefined; + const udid = readDictEntry(properties, 'SerialNumber')?.text; + const id = Number(readDictEntry(node, 'DeviceID')?.text); + if (!udid || !Number.isSafeInteger(id) || id <= 0) return undefined; + return { udid, id }; +} + +function readPlistInteger(xml: string, key: string): number | undefined { + for (const node of walkXmlNodes(parseXmlDocumentSync(xml))) { + if (node.name !== 'dict') continue; + const value = readDictEntry(node, key); + if (value?.name !== 'integer') continue; + const parsed = Number(value.text); + if (Number.isSafeInteger(parsed)) return parsed; + } + return undefined; +} + +function readDictEntry(dict: XmlNode, key: string): XmlNode | undefined { + for (let index = 0; index < dict.children.length - 1; index += 1) { + const entry = dict.children[index]; + if (entry?.name === 'key' && entry.text === key) return dict.children[index + 1]; + } + return undefined; +} + +function* walkXmlNodes(nodes: readonly XmlNode[]): Generator { + for (const node of nodes) { + yield node; + yield* walkXmlNodes(node.children); + } +} + +function hostToNetworkPort(port: number): number { + return ((port & 0xff) << 8) | ((port >>> 8) & 0xff); +} + +function requireTimeRemaining(timeoutMs: number, action: string): void { + if (timeoutMs > 0) return; + throw new AppError('COMMAND_FAILED', `No time remaining to ${action}`, { + backend: 'xctest', + timeoutMs, + }); +} + +function escapeXml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/src/platforms/apple/core/runner/runner-usbmux.ts b/src/platforms/apple/core/runner/runner-usbmux.ts new file mode 100644 index 000000000..676d5a8b1 --- /dev/null +++ b/src/platforms/apple/core/runner/runner-usbmux.ts @@ -0,0 +1,129 @@ +import http, { type IncomingMessage } from 'node:http'; +import { type Socket } from 'node:net'; +import { AppError } from '../../../../kernel/errors.ts'; +import { createRequestCanceledError } from '../../../../request/cancel.ts'; +import { Deadline } from '../../../../utils/retry.ts'; +import type { RunnerCommand } from './runner-contract.ts'; +import { openUsbmuxRunnerSocket } from './runner-usbmux-protocol.ts'; + +const USBMUXD_SOCKET_PATH = '/var/run/usbmuxd'; +const RUNNER_HTTP_MAX_BODY_BYTES = 64 * 1024 * 1024; + +export type UsbmuxRunnerTransport = { + postCommand( + deviceId: string, + port: number, + command: RunnerCommand, + timeoutMs: number, + signal?: AbortSignal, + ): Promise; +}; + +export function createUsbmuxRunnerTransport(socketPath: string): UsbmuxRunnerTransport { + return { + postCommand: async (deviceId, port, command, timeoutMs, signal) => { + const deadline = Deadline.fromTimeoutMs(timeoutMs); + const socket = await openUsbmuxRunnerSocket( + socketPath, + deviceId, + port, + deadline.remainingMs(), + signal, + ); + try { + return await postRunnerHttpCommand(socket, command, deadline.remainingMs(), signal); + } catch (error) { + socket.destroy(); + throw error; + } + }, + }; +} + +export const usbmuxRunnerTransport = createUsbmuxRunnerTransport(USBMUXD_SOCKET_PATH); + +async function postRunnerHttpCommand( + socket: Socket, + command: RunnerCommand, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + requireTimeRemaining(timeoutMs); + if (signal?.aborted) throw createRequestCanceledError(); + const body = Buffer.from(JSON.stringify(command), 'utf8'); + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; + const agent = new http.Agent(); + agent.createConnection = (_options, callback) => { + callback?.(null, socket); + return socket; + }; + try { + const response = await requestRunnerCommand(agent, socket, body, requestSignal); + return new Response((await readBoundedResponseBody(response)).toString('utf8'), { + status: response.statusCode ?? 500, + }); + } catch (error) { + if (signal?.aborted) throw createRequestCanceledError(); + if (timeoutSignal.aborted) { + throw new AppError('COMMAND_FAILED', 'Timed out waiting for XCTest runner over usbmux', { + backend: 'xctest', + timeoutMs, + }); + } + throw error; + } finally { + agent.destroy(); + socket.destroy(); + } +} + +async function requestRunnerCommand( + agent: http.Agent, + socket: Socket, + body: Buffer, + signal: AbortSignal, +): Promise { + return await new Promise((resolve, reject) => { + const request = http.request( + { + method: 'POST', + host: '127.0.0.1', + path: '/command', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': body.length, + Connection: 'close', + }, + agent, + signal, + }, + resolve, + ); + request.once('error', reject); + socket.resume(); + request.end(body); + }); +} + +async function readBoundedResponseBody(response: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let bodyBytes = 0; + for await (const chunk of response) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bodyBytes += buffer.length; + if (bodyBytes > RUNNER_HTTP_MAX_BODY_BYTES) { + throw new AppError('COMMAND_FAILED', 'XCTest runner response exceeded size limit'); + } + chunks.push(buffer); + } + return Buffer.concat(chunks); +} + +function requireTimeRemaining(timeoutMs: number): void { + if (timeoutMs > 0) return; + throw new AppError('COMMAND_FAILED', 'No time remaining to send XCTest runner command', { + backend: 'xctest', + timeoutMs, + }); +} diff --git a/src/platforms/apple/core/screenshot.ts b/src/platforms/apple/core/screenshot.ts index 5887ba623..4b68a8956 100644 --- a/src/platforms/apple/core/screenshot.ts +++ b/src/platforms/apple/core/screenshot.ts @@ -24,6 +24,7 @@ import { ensureBootedSimulator } from './simulator.ts'; import { runSimctlForDevice } from './simctl.ts'; import { extractAppleToolErrorMeta } from './tool-diagnostics.ts'; import { runXcrun } from './tool-provider.ts'; +import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; function runSimctl(device: DeviceInfo, args: string[], options?: ExecOptions) { return runSimctlForDevice(device, args, options); @@ -217,9 +218,16 @@ export async function captureScreenshotViaRunner( command: 'screenshot', appBundleId, fullscreen, + inlineScreenshot: + device.kind === 'device' && resolveIosPhysicalDeviceControl(device).backend === 'xctest', }, runnerOptions, ); + const inlineImage = result['imageBase64']; + if (typeof inlineImage === 'string' && inlineImage.length > 0) { + await fs.writeFile(outPath, Buffer.from(inlineImage, 'base64')); + return; + } const remoteFileName = result['message'] as string; if (!remoteFileName) { throw new AppError( diff --git a/src/platforms/apple/interactor.ts b/src/platforms/apple/interactor.ts index de4120c27..7050647d4 100644 --- a/src/platforms/apple/interactor.ts +++ b/src/platforms/apple/interactor.ts @@ -51,9 +51,10 @@ export function createAppleInteractor( launchArgs: options?.launchArgs, terminateRunningApp: options?.terminateRunningApp, url: options?.url, + runnerOptions: runnerOpts, }), openDevice: () => openIosDevice(device), - close: (app) => closeIosApp(device, app), + close: (app) => closeIosApp(device, app, runnerOpts), screenshot: (outPath, options) => runAppleScreenshot(device, outPath, options, runnerOpts), snapshot: async (options) => { const result = readAppleSnapshotResult( diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index ad4657ba9..858d4fe1c 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1007,8 +1007,9 @@ agent-device artifacts --provider aws-device-farm --provider-session