From 46879ad5224c399d550beca54f5e61fa4358c90b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 16:17:41 +0200 Subject: [PATCH 1/3] refactor(ios): build every simctl set prefix in core/simctl and merge the state parsers Every simctl call site now scopes its argv through core/simctl.ts (scopeSimctlArgs/scopeSimctlArgsForDevice for the host tool seam, buildSimctlArgs/buildSimctlArgsForDevice for xcrun), and every direct run goes through runSimctlForDevice. The runSimctl/simctlArgs alias in apps-simctl.ts, the simulator-state.ts builder, and the screenshot shims are gone. The two `simctl list devices -j` state parsers collapse into readSimctlDeviceState; the snapshot target's runtime projection shares its raw reader. Inventory keeps its strict projection. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/core/__tests__/app-device-io.test.ts | 5 ++- .../src/core/__tests__/app-resolution.test.ts | 5 ++- .../core/__tests__/simctl-device-list.test.ts | 42 +++++++++++++++++++ .../src/core/__tests__/simctl.test.ts | 36 +++++++++++++++- .../platform-apple/src/core/app-device-io.ts | 8 ++-- .../platform-apple/src/core/app-launch.ts | 8 ++-- .../platform-apple/src/core/app-resolution.ts | 6 +-- .../platform-apple/src/core/app-settings.ts | 29 +++++++------ .../platform-apple/src/core/apps-simctl.ts | 16 ------- .../src/core/screenshot-status-bar.ts | 12 ++---- .../platform-apple/src/core/screenshot.ts | 19 ++++----- .../src/core/settings-text-size.ts | 6 +-- .../src/core/simctl-device-list.ts | 20 +++++++++ packages/platform-apple/src/core/simctl.ts | 32 ++++++++++---- packages/platform-apple/src/core/simulator.ts | 16 +------ .../platform-apple/src/deployment/runtime.ts | 11 +++-- .../platform-apple/src/logs/log-predicate.ts | 33 ++++++++------- packages/platform-apple/src/logs/start.ts | 4 +- .../platform-apple/src/network/runtime.ts | 6 +-- .../platform-apple/src/readiness/runtime.ts | 13 ++++-- .../platform-apple/src/shutdown/runtime.ts | 5 ++- .../platform-apple/src/simulator-inventory.ts | 4 +- .../platform-apple/src/simulator-state.ts | 21 ++-------- .../platform-apple/src/snapshot-target.ts | 12 +++--- 24 files changed, 226 insertions(+), 143 deletions(-) create mode 100644 packages/platform-apple/src/core/__tests__/simctl-device-list.test.ts create mode 100644 packages/platform-apple/src/core/simctl-device-list.ts diff --git a/packages/platform-apple/src/core/__tests__/app-device-io.test.ts b/packages/platform-apple/src/core/__tests__/app-device-io.test.ts index c82875ac0e..374026368c 100644 --- a/packages/platform-apple/src/core/__tests__/app-device-io.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-device-io.test.ts @@ -12,7 +12,10 @@ vi.mock('../simulator.ts', () => ({ if (device.kind !== 'simulator') throw new Error('simulator required'); }, })); -vi.mock('../apps-simctl.ts', () => ({ runSimctl })); +vi.mock('../simctl.ts', async (importOriginal) => ({ + ...(await importOriginal()), + runSimctlForDevice: runSimctl, +})); import { pushIosNotification } from '../app-device-io.ts'; diff --git a/packages/platform-apple/src/core/__tests__/app-resolution.test.ts b/packages/platform-apple/src/core/__tests__/app-resolution.test.ts index 1966aa1b7d..8567df0f36 100644 --- a/packages/platform-apple/src/core/__tests__/app-resolution.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-resolution.test.ts @@ -3,7 +3,10 @@ import { beforeEach, test, vi } from 'vitest'; const { mockRunSimctl } = vi.hoisted(() => ({ mockRunSimctl: vi.fn() })); -vi.mock('../apps-simctl.ts', () => ({ runSimctl: mockRunSimctl })); +vi.mock('../simctl.ts', async (importOriginal) => ({ + ...(await importOriginal()), + runSimctlForDevice: mockRunSimctl, +})); import { detectSoleRunningIosSimulatorApp, diff --git a/packages/platform-apple/src/core/__tests__/simctl-device-list.test.ts b/packages/platform-apple/src/core/__tests__/simctl-device-list.test.ts new file mode 100644 index 0000000000..d258bd64d9 --- /dev/null +++ b/packages/platform-apple/src/core/__tests__/simctl-device-list.test.ts @@ -0,0 +1,42 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { readSimctlDevicesByRuntime, readSimctlDeviceState } from '../simctl-device-list.ts'; + +const LISTING = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: 'sim-a', state: 'Shutdown', name: 'iPhone 16' }, + ], + 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [ + { udid: 'sim-b', state: 'Booted', name: 'iPhone 17' }, + ], + }, +}); + +test('readSimctlDeviceState reports the listed state of the requested simulator', () => { + assert.equal(readSimctlDeviceState(LISTING, 'sim-b'), 'Booted'); + assert.equal(readSimctlDeviceState(LISTING, 'sim-a'), 'Shutdown'); +}); + +test('readSimctlDeviceState is null for an unlisted, unreadable, or empty listing', () => { + assert.equal(readSimctlDeviceState(LISTING, 'sim-missing'), null); + assert.equal(readSimctlDeviceState('not json', 'sim-b'), null); + assert.equal(readSimctlDeviceState('{}', 'sim-b'), null); + assert.equal(readSimctlDeviceState(JSON.stringify({ devices: { runtime: {} } }), 'sim-b'), null); +}); + +test('readSimctlDevicesByRuntime keys each device list by its runtime', () => { + const devicesByRuntime = readSimctlDevicesByRuntime(LISTING); + assert.deepEqual( + Object.entries(devicesByRuntime).map(([runtime, devices]) => [ + runtime, + devices.map(({ udid }) => udid), + ]), + [ + ['com.apple.CoreSimulator.SimRuntime.iOS-18-0', ['sim-a']], + ['com.apple.CoreSimulator.SimRuntime.iOS-26-0', ['sim-b']], + ], + ); + assert.deepEqual(readSimctlDevicesByRuntime('{}'), {}); + assert.throws(() => readSimctlDevicesByRuntime('not json'), SyntaxError); +}); diff --git a/packages/platform-apple/src/core/__tests__/simctl.test.ts b/packages/platform-apple/src/core/__tests__/simctl.test.ts index cc37aa8a62..73a5477fef 100644 --- a/packages/platform-apple/src/core/__tests__/simctl.test.ts +++ b/packages/platform-apple/src/core/__tests__/simctl.test.ts @@ -1,6 +1,11 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { buildSimctlArgs, buildSimctlArgsForDevice } from '../simctl.ts'; +import { + buildSimctlArgs, + buildSimctlArgsForDevice, + scopeSimctlArgs, + scopeSimctlArgsForDevice, +} from '../simctl.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; const IOS_SIMULATOR: DeviceInfo = { @@ -48,3 +53,32 @@ test('buildSimctlArgsForDevice leaves non-simulator commands unchanged', () => { ]); assert.deepEqual(args, ['simctl', 'bootstatus', 'sim-1', '-b']); }); + +test('scopeSimctlArgs prefixes a trimmed simulator set and omits a blank one', () => { + assert.deepEqual(scopeSimctlArgs(['list', 'devices', '-j'], { simulatorSetPath: ' /tmp/set ' }), [ + '--set', + '/tmp/set', + 'list', + 'devices', + '-j', + ]); + assert.deepEqual(scopeSimctlArgs(['list', 'devices', '-j'], { simulatorSetPath: ' ' }), [ + 'list', + 'devices', + '-j', + ]); +}); + +test('scopeSimctlArgsForDevice scopes simulators only', () => { + const scoped = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-c/simulator-set' }; + assert.deepEqual(scopeSimctlArgsForDevice(scoped, ['shutdown', 'sim-1']), [ + '--set', + '/tmp/tenant-c/simulator-set', + 'shutdown', + 'sim-1', + ]); + assert.deepEqual(scopeSimctlArgsForDevice({ ...scoped, kind: 'device' }, ['shutdown', 'sim-1']), [ + 'shutdown', + 'sim-1', + ]); +}); diff --git a/packages/platform-apple/src/core/app-device-io.ts b/packages/platform-apple/src/core/app-device-io.ts index 99aaca3d4f..42ea707965 100644 --- a/packages/platform-apple/src/core/app-device-io.ts +++ b/packages/platform-apple/src/core/app-device-io.ts @@ -8,7 +8,7 @@ import { } from '@agent-device/host-kit/host-file'; import { ensureBootedSimulator, requireSimulatorDevice } from './simulator.ts'; import { readMacOsClipboardText, writeMacOsClipboardText } from '../os/macos/apps.ts'; -import { runSimctl } from './apps-simctl.ts'; +import { runSimctlForDevice } from './simctl.ts'; export async function readIosClipboardText(device: DeviceInfo): Promise { if (isMacOs(device)) { @@ -17,7 +17,7 @@ export async function readIosClipboardText(device: DeviceInfo): Promise requireSimulatorDevice(device, 'clipboard'); await ensureBootedSimulator(device); const result = requireExecSuccess( - await runSimctl(device, ['pbpaste', device.id], { allowFailure: true }), + await runSimctlForDevice(device, ['pbpaste', device.id], { allowFailure: true }), 'Failed to read iOS simulator clipboard', ); return result.stdout.replaceAll('\r\n', '\n').replace(/\n$/, ''); @@ -31,7 +31,7 @@ export async function writeIosClipboardText(device: DeviceInfo, text: string): P requireSimulatorDevice(device, 'clipboard'); await ensureBootedSimulator(device); requireExecSuccess( - await runSimctl(device, ['pbcopy', device.id], { + await runSimctlForDevice(device, ['pbcopy', device.id], { allowFailure: true, stdin: text, }), @@ -52,7 +52,7 @@ export async function pushIosNotification( const payloadPath = path.join(tempDir, 'payload.apns'); try { await writeHostTextFile(payloadPath, `${JSON.stringify(payload)}\n`); - await runSimctl(device, ['push', device.id, bundleId, payloadPath], { + await runSimctlForDevice(device, ['push', device.id, bundleId, payloadPath], { signal: options.signal, }); } finally { diff --git a/packages/platform-apple/src/core/app-launch.ts b/packages/platform-apple/src/core/app-launch.ts index 4c02959798..652eeb3ab4 100644 --- a/packages/platform-apple/src/core/app-launch.ts +++ b/packages/platform-apple/src/core/app-launch.ts @@ -30,7 +30,7 @@ import { ensureBootedSimulator } from './simulator.ts'; import { runXcrun } from './tool-provider.ts'; import { closeMacOsApp, openMacOsApp } from '../os/macos/apps.ts'; import { resolveIosApp } from './app-resolution.ts'; -import { runSimctl, simctlArgs } from './apps-simctl.ts'; +import { buildSimctlArgsForDevice, runSimctlForDevice } from './simctl.ts'; const IOS_SIMULATOR_CONSOLE_CAPTURE_MS = 25_000; const IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE = @@ -153,7 +153,7 @@ async function openIosSimulatorUrl( throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE); } await ensureBootedSimulator(device); - await runSimctl(device, ['openurl', device.id, url]); + await runSimctlForDevice(device, ['openurl', device.id, url]); } export async function openIosDevice(device: DeviceInfo): Promise { @@ -208,7 +208,7 @@ async function assertNotSystemSurfaceHost(bundleId: string): Promise { async function terminateIosSimulatorApp(device: DeviceInfo, bundleId: string): Promise { await assertNotSystemSurfaceHost(bundleId); await ensureBootedSimulator(device); - const terminateArgs = simctlArgs(device, ['terminate', device.id, bundleId]); + const terminateArgs = buildSimctlArgsForDevice(device, ['terminate', device.id, bundleId]); const result = await runXcrun(terminateArgs, { allowFailure: true, timeoutMs: IOS_SIMULATOR_TERMINATE_TIMEOUT_MS, @@ -244,7 +244,7 @@ async function launchIosSimulatorApp( }); } - const launchArgs = simctlArgs( + const launchArgs = buildSimctlArgsForDevice( device, buildIosSimulatorLaunchArgs(device.id, bundleId, options), ); diff --git a/packages/platform-apple/src/core/app-resolution.ts b/packages/platform-apple/src/core/app-resolution.ts index 2e7cda261b..e2aeb78500 100644 --- a/packages/platform-apple/src/core/app-resolution.ts +++ b/packages/platform-apple/src/core/app-resolution.ts @@ -13,7 +13,7 @@ import { filterAppleAppsByBundlePrefix } from './app-filter.ts'; import { buildAppNotInstalledError } from './app-resolution-error.ts'; import { listMacApps, resolveMacOsApp } from '../os/macos/apps.ts'; import { runAppleToolCommand } from './tool-provider.ts'; -import { runSimctl } from './apps-simctl.ts'; +import { runSimctlForDevice } from './simctl.ts'; import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; import { createTtlMemo } from '@agent-device/kernel/ttl-memo'; @@ -147,7 +147,7 @@ export async function detectSoleRunningIosSimulatorApp( } async function listRunningIosSimulatorBundleIds(device: DeviceInfo): Promise { - const result = await runSimctl(device, ['spawn', device.id, 'launchctl', 'list'], { + const result = await runSimctlForDevice(device, ['spawn', device.id, 'launchctl', 'list'], { allowFailure: true, timeoutMs: IOS_FOREGROUND_APP_PROBE_TIMEOUT_MS, }); @@ -229,7 +229,7 @@ async function listSimulatorAppMetadata( device: DeviceInfo, options?: SimulatorAppListOptions, ): Promise { - const result = await runSimctl(device, ['listapps', device.id], { + const result = await runSimctlForDevice(device, ['listapps', device.id], { allowFailure: true, timeoutMs: options?.timeoutMs, }); diff --git a/packages/platform-apple/src/core/app-settings.ts b/packages/platform-apple/src/core/app-settings.ts index f9ba502ac2..b2d1d25896 100644 --- a/packages/platform-apple/src/core/app-settings.ts +++ b/packages/platform-apple/src/core/app-settings.ts @@ -24,7 +24,7 @@ import { runMacOsPermissionAction, type MacOsPermissionTarget } from '../os/maco import { closeIosApp } from './app-launch.ts'; import { readIosTextSize, setIosTextSize } from './settings-text-size.ts'; import { resolveIosApp } from './app-resolution.ts'; -import { runSimctl, simctlArgs } from './apps-simctl.ts'; +import { buildSimctlArgsForDevice, runSimctlForDevice } from './simctl.ts'; import { invalidateSimulatorStatusBarOverrideCache, rememberClearedStatusBarOverrides, @@ -78,7 +78,7 @@ export async function setIosSetting( if (state.toLowerCase() !== 'clear') { throw new AppError('INVALID_ARGS', 'settings reset-keychain only supports clear.'); } - await runSimctl(device, ['keychain', device.id, 'reset']); + await runSimctlForDevice(device, ['keychain', device.id, 'reset']); return { scope: 'simulator', cleared: true, @@ -89,14 +89,14 @@ export async function setIosSetting( case 'wifi': { const enabled = parseSettingState(state); const mode = enabled ? 'active' : 'failed'; - await runSimctl(device, ['status_bar', device.id, 'override', '--wifiMode', mode]); + await runSimctlForDevice(device, ['status_bar', device.id, 'override', '--wifiMode', mode]); invalidateSimulatorStatusBarOverrideCache(device); return; } case 'airplane': { const enabled = parseSettingState(state); if (enabled) { - await runSimctl(device, [ + await runSimctlForDevice(device, [ 'status_bar', device.id, 'override', @@ -115,7 +115,7 @@ export async function setIosSetting( ]); invalidateSimulatorStatusBarOverrideCache(device); } else { - await runSimctl(device, ['status_bar', device.id, 'clear']); + await runSimctlForDevice(device, ['status_bar', device.id, 'clear']); rememberClearedStatusBarOverrides(device); } return; @@ -123,7 +123,12 @@ export async function setIosSetting( case 'location': { if (state.toLowerCase() === 'set') { const { latitude, longitude } = requireLocationCoordinates(options); - await runSimctl(device, ['location', device.id, 'set', `${latitude},${longitude}`]); + await runSimctlForDevice(device, [ + 'location', + device.id, + 'set', + `${latitude},${longitude}`, + ]); return { latitude, longitude }; } const enabled = parseSettingState(state); @@ -131,7 +136,7 @@ export async function setIosSetting( throw new AppError('INVALID_ARGS', 'location setting requires an active app in session'); } const action = enabled ? 'grant' : 'revoke'; - await runSimctl(device, ['privacy', device.id, action, 'location', appBundleId]); + await runSimctlForDevice(device, ['privacy', device.id, action, 'location', appBundleId]); return; } case 'faceid': @@ -148,7 +153,7 @@ export async function setIosSetting( } case 'appearance': { const target = await resolveIosAppearanceTarget(device, state); - await runSimctl(device, ['ui', device.id, 'appearance', target]); + await runSimctlForDevice(device, ['ui', device.id, 'appearance', target]); return; } case 'text-size': { @@ -201,7 +206,7 @@ async function clearIosSimulatorAppState( await closeIosApp(device, bundleId); const result = requireExecSuccess( - await runSimctl(device, ['get_app_container', device.id, bundleId, 'data'], { + await runSimctlForDevice(device, ['get_app_container', device.id, bundleId, 'data'], { allowFailure: true, }), `simctl get_app_container failed for ${bundleId}`, @@ -244,7 +249,7 @@ async function resolveIosAppearanceTarget( if (action !== 'toggle') return action; const currentResult = requireExecSuccess( - await runSimctl(device, ['ui', device.id, 'appearance'], { + await runSimctlForDevice(device, ['ui', device.id, 'appearance'], { allowFailure: true, }), 'Failed to read current iOS appearance', @@ -291,7 +296,7 @@ async function runIosPrivacyCommand( appBundleId: string, ): Promise { try { - await runSimctl(device, ['privacy', device.id, action, target, appBundleId]); + await runSimctlForDevice(device, ['privacy', device.id, action, target, appBundleId]); } catch (error) { if (!isPrivacyServiceRefusedError(error)) throw error; throw privacyServiceRefusedError(device, action, target, appBundleId, error); @@ -406,7 +411,7 @@ async function runIosBiometricSimctlCommand( const failures: CommandAttemptFailure[] = []; for (const args of attempts) { - const commandArgs = simctlArgs(device, args); + const commandArgs = buildSimctlArgsForDevice(device, args); const result = await runXcrun(commandArgs, { allowFailure: true }); if (result.exitCode === 0) return; failures.push({ diff --git a/packages/platform-apple/src/core/apps-simctl.ts b/packages/platform-apple/src/core/apps-simctl.ts index c5e0d04abe..54dd55f6d7 100644 --- a/packages/platform-apple/src/core/apps-simctl.ts +++ b/packages/platform-apple/src/core/apps-simctl.ts @@ -1,19 +1,3 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { buildSimctlArgsForDevice } from './simctl.ts'; -import { runXcrun } from './tool-provider.ts'; - -export function simctlArgs(device: DeviceInfo, args: string[]): string[] { - return buildSimctlArgsForDevice(device, args); -} - -export function runSimctl( - device: DeviceInfo, - args: string[], - options?: Parameters[1], -) { - return runXcrun(simctlArgs(device, args), options); -} - export function isMissingAppErrorOutput(output: string): boolean { return ( output.includes('not installed') || diff --git a/packages/platform-apple/src/core/screenshot-status-bar.ts b/packages/platform-apple/src/core/screenshot-status-bar.ts index 0ba2eb5a82..42233be14c 100644 --- a/packages/platform-apple/src/core/screenshot-status-bar.ts +++ b/packages/platform-apple/src/core/screenshot-status-bar.ts @@ -1,5 +1,5 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; -import { requireExecSuccess, type ExecOptions } from '@agent-device/host-kit/command'; +import { requireExecSuccess } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { AppError } from '@agent-device/kernel/errors'; @@ -55,10 +55,6 @@ const CELLULAR_MODE_BY_CODE: Record = { 3: 'active', }; -function runSimctl(device: DeviceInfo, args: string[], options?: ExecOptions) { - return runSimctlForDevice(device, args, options); -} - const devicesKnownWithoutStatusBarOverrides = new Set(); export async function prepareSimulatorStatusBarForScreenshot( @@ -130,7 +126,7 @@ async function readSimulatorStatusBarOverrides( device: DeviceInfo, ): Promise { const result = requireExecSuccess( - await runSimctl(device, ['status_bar', device.id, 'list'], { + await runSimctlForDevice(device, ['status_bar', device.id, 'list'], { allowFailure: true, }), 'Failed to read simulator status bar overrides', @@ -139,7 +135,7 @@ async function readSimulatorStatusBarOverrides( } async function clearSimulatorStatusBarOverride(device: DeviceInfo): Promise { - await runSimctl(device, ['status_bar', device.id, 'clear']); + await runSimctlForDevice(device, ['status_bar', device.id, 'clear']); } async function applySimulatorStatusBarOverrideArgs( @@ -147,7 +143,7 @@ async function applySimulatorStatusBarOverrideArgs( args: string[], ): Promise { if (args.length === 0) return; - await runSimctl(device, ['status_bar', device.id, 'override', ...args]); + await runSimctlForDevice(device, ['status_bar', device.id, 'override', ...args]); } function parseSimulatorStatusBarOverrides(output: string): RestorableStatusBarOverrides | null { diff --git a/packages/platform-apple/src/core/screenshot.ts b/packages/platform-apple/src/core/screenshot.ts index a4d00c1a73..b966b51ac4 100644 --- a/packages/platform-apple/src/core/screenshot.ts +++ b/packages/platform-apple/src/core/screenshot.ts @@ -1,6 +1,5 @@ import path from 'node:path'; import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; -import { type ExecOptions } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { copyHostFile } from '@agent-device/host-kit/host-file'; import { Deadline, retryWithPolicy } from '@agent-device/host-kit/retry'; @@ -37,10 +36,6 @@ import { runSimctlForDevice } from './simctl.ts'; import { appleToolFailureText, extractAppleToolErrorMeta } from './tool-diagnostics.ts'; import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; -function runSimctl(device: DeviceInfo, args: string[], options?: ExecOptions) { - return runSimctlForDevice(device, args, options); -} - type SimulatorScreenshotFlowDeps = { ensureBooted: (device: DeviceInfo) => Promise; prepareStatusBarForScreenshot: (device: DeviceInfo) => Promise<() => Promise>; @@ -213,7 +208,7 @@ export async function captureSimulatorScreenshotWithRetry( ]; await retryWithPolicy( async ({ deadline: attemptDeadline }) => { - await runSimctl(device, argv, { + await runSimctlForDevice(device, argv, { timeoutMs: Math.max( 1_000, attemptDeadline?.remainingMs() ?? IOS_SIMULATOR_SCREENSHOT_TIMEOUT_MS, @@ -299,7 +294,7 @@ async function copyRunnerScreenshotFromSimulator( iosSimulatorRunnerContainerCache.delete(device.id); } for (const bundleId of IOS_RUNNER_CONTAINER_BUNDLE_IDS) { - const containerResult = await runSimctl( + const containerResult = await runSimctlForDevice( device, ['get_app_container', device.id, bundleId, 'data'], { @@ -496,9 +491,13 @@ async function readIosSimulatorMainScreenScale(device: DeviceInfo): Promise { requireTextSizeLeaf(device); const category = parseTextSizeCategory(state); - await runSimctl(device, ['ui', device.id, 'content_size', category]); + await runSimctlForDevice(device, ['ui', device.id, 'content_size', category]); return textSizeSettingPayload(category, category); } @@ -65,7 +65,7 @@ export async function readIosTextSize(device: DeviceInfo): Promise { + const payload = JSON.parse(stdout) as { devices?: Record }; + return payload.devices ?? {}; +} + +/** The listed state of one simulator; null when the listing is unreadable or omits the device. */ +export function readSimctlDeviceState(stdout: string, udid: string): string | null { + try { + for (const devices of Object.values(readSimctlDevicesByRuntime(stdout))) { + const match = devices.find((entry) => entry.udid === udid); + if (match) return match.state ?? null; + } + return null; + } catch { + return null; + } +} diff --git a/packages/platform-apple/src/core/simctl.ts b/packages/platform-apple/src/core/simctl.ts index 3997a6f4f1..8f83f0514a 100644 --- a/packages/platform-apple/src/core/simctl.ts +++ b/packages/platform-apple/src/core/simctl.ts @@ -7,22 +7,36 @@ type SimctlArgsOptions = { simulatorSetPath?: string; }; -export function buildSimctlArgs(args: string[], options: SimctlArgsOptions = {}): string[] { +/** Arguments that follow the `simctl` tool name, scoped to the simulator set when one is given. */ +export function scopeSimctlArgs( + args: readonly string[], + options: SimctlArgsOptions = {}, +): string[] { const simulatorSetPath = resolveIosSimulatorDeviceSetPath(options.simulatorSetPath); - if (!simulatorSetPath) return ['simctl', ...args]; - return ['simctl', '--set', simulatorSetPath, ...args]; + if (!simulatorSetPath) return [...args]; + return ['--set', simulatorSetPath, ...args]; } -export function buildSimctlArgsForDevice(device: DeviceInfo, args: string[]): string[] { - if (!isIosFamily(device) || device.kind !== 'simulator') { - return ['simctl', ...args]; - } - return buildSimctlArgs(args, { simulatorSetPath: device.simulatorSetPath }); +/** Arguments that follow the `simctl` tool name, scoped to the simulator set holding the device. */ +export function scopeSimctlArgsForDevice(device: DeviceInfo, args: readonly string[]): string[] { + if (!isIosFamily(device) || device.kind !== 'simulator') return [...args]; + return scopeSimctlArgs(args, { simulatorSetPath: device.simulatorSetPath }); +} + +export function buildSimctlArgs( + args: readonly string[], + options: SimctlArgsOptions = {}, +): string[] { + return ['simctl', ...scopeSimctlArgs(args, options)]; +} + +export function buildSimctlArgsForDevice(device: DeviceInfo, args: readonly string[]): string[] { + return ['simctl', ...scopeSimctlArgsForDevice(device, args)]; } export function runSimctlForDevice( device: DeviceInfo, - args: string[], + args: readonly string[], options?: ExecOptions, ): Promise { return runXcrun(buildSimctlArgsForDevice(device, args), options); diff --git a/packages/platform-apple/src/core/simulator.ts b/packages/platform-apple/src/core/simulator.ts index 106aae9e6b..254eab3b98 100644 --- a/packages/platform-apple/src/core/simulator.ts +++ b/packages/platform-apple/src/core/simulator.ts @@ -13,6 +13,7 @@ import { IOS_SIMULATOR_FOCUS_TIMEOUT_MS, } from './config.ts'; import { buildSimctlArgsForDevice } from './simctl.ts'; +import { readSimctlDeviceState } from './simctl-device-list.ts'; import { runAppleToolCommand, runXcrun } from './tool-provider.ts'; const IOS_SIMULATOR_HOST_APPS = ['Simulator'] as const; @@ -262,18 +263,5 @@ async function getSimulatorState(device: DeviceInfo, signal?: AbortSignal): Prom timeoutMs: IOS_SIMCTL_LIST_TIMEOUT_MS, }); if (result.exitCode !== 0) return null; - - try { - const payload = JSON.parse(result.stdout) as { - devices: Record; - }; - - for (const runtime of Object.values(payload.devices ?? {})) { - const match = runtime.find((entry) => entry.udid === device.id); - if (match) return match.state; - } - return null; - } catch { - return null; - } + return readSimctlDeviceState(result.stdout, device.id); } diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index d480a69f3e..394310eb70 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -11,7 +11,7 @@ import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runt import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { ensureAppleReady } from '../readiness/runtime.ts'; -import { simctlArgs } from '../simulator-state.ts'; +import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; const available = Object.freeze({ available: true } as const); const coreDeviceRequired = Object.freeze({ @@ -137,7 +137,10 @@ async function installAppleApp( await ensureAppleReady(host, device, signal); const result = await host.appleTools.run( device.kind === 'simulator' - ? { tool: 'simctl', args: simctlArgs(device, ['install', device.id, installablePath]) } + ? { + tool: 'simctl', + args: scopeSimctlArgsForDevice(device, ['install', device.id, installablePath]), + } : { tool: 'devicectl', args: ['device', 'install', 'app', '--device', device.id, installablePath], @@ -159,7 +162,7 @@ async function uninstallAppleApp( device.kind === 'simulator' ? { tool: 'simctl', - args: simctlArgs(device, ['uninstall', device.id, bundleId]), + args: scopeSimctlArgsForDevice(device, ['uninstall', device.id, bundleId]), allowFailure: true, } : { @@ -192,7 +195,7 @@ async function pushAppleNotification( const result = await host.appleTools.run( { tool: 'simctl', - args: simctlArgs(device, ['push', device.id, input.appId, payload.path]), + args: scopeSimctlArgsForDevice(device, ['push', device.id, input.appId, payload.path]), }, signal, ); diff --git a/packages/platform-apple/src/logs/log-predicate.ts b/packages/platform-apple/src/logs/log-predicate.ts index 8669df5549..4fa3e598de 100644 --- a/packages/platform-apple/src/logs/log-predicate.ts +++ b/packages/platform-apple/src/logs/log-predicate.ts @@ -1,3 +1,5 @@ +import { buildSimctlArgs } from '../core/simctl.ts'; + export function buildAppleLogPredicate(appBundleId: string, executableName?: string): string { const escapedBundleId = escapePredicateString(appBundleId); const clauses = [ @@ -25,22 +27,21 @@ export function buildIosSimulatorLogStreamArgs(params: { executableName?: string; simulatorSetPath?: string; }): string[] { - const simctlPrefix = params.simulatorSetPath - ? ['simctl', '--set', params.simulatorSetPath] - : ['simctl']; - return [ - ...simctlPrefix, - 'spawn', - params.deviceId, - 'log', - 'stream', - '--style', - 'compact', - '--level', - 'info', - '--predicate', - buildAppleLogPredicate(params.appBundleId, params.executableName), - ]; + return buildSimctlArgs( + [ + 'spawn', + params.deviceId, + 'log', + 'stream', + '--style', + 'compact', + '--level', + 'info', + '--predicate', + buildAppleLogPredicate(params.appBundleId, params.executableName), + ], + { simulatorSetPath: params.simulatorSetPath }, + ); } export function buildIosDeviceConsoleLaunchArgs(deviceId: string, appBundleId: string): string[] { diff --git a/packages/platform-apple/src/logs/start.ts b/packages/platform-apple/src/logs/start.ts index a615d73dcf..a25b3a67e8 100644 --- a/packages/platform-apple/src/logs/start.ts +++ b/packages/platform-apple/src/logs/start.ts @@ -17,6 +17,7 @@ import { createAppLogLiveHandleFromFinish, createAppLogStartResult, } from '@agent-device/capture-kit'; +import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; import { APPLE_XCTEST_LOGS_HINT, backendForAppleDevice } from './backend.ts'; import { checkCoreDeviceConsoleCaptureSupport, @@ -174,11 +175,10 @@ async function resolveSimulatorExecutable( appBundleId: string, signal?: AbortSignal, ): Promise { - const prefix = device.simulatorSetPath ? ['--set', device.simulatorSetPath] : []; const container = await host.appleTools.run( { tool: 'simctl', - args: [...prefix, 'get_app_container', device.id, appBundleId, 'app'], + args: scopeSimctlArgsForDevice(device, ['get_app_container', device.id, appBundleId, 'app']), allowFailure: true, timeoutMs: 4_000, }, diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index a1f3b0a2a9..cc9b021c89 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -7,6 +7,7 @@ import { type NetworkScan, } from '@agent-device/capture-kit'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; import { backendForAppleDevice } from '../logs/backend.ts'; export async function dumpAppleNetworkTraffic( @@ -88,8 +89,7 @@ async function recoverSimulatorTraffic( appLogPath: string, signal: AbortSignal, ): Promise<{ scan: NetworkScan; lineCount: number } | undefined> { - const args = [ - ...(device.simulatorSetPath ? ['--set', device.simulatorSetPath] : []), + const args = scopeSimctlArgsForDevice(device, [ 'spawn', device.id, 'log', @@ -99,7 +99,7 @@ async function recoverSimulatorTraffic( '--info', '--predicate', buildPredicate(input.appBundleId as string), - ]; + ]); const startedAt = input.appLogSnapshot?.startedAt; args.push( ...(typeof startedAt === 'number' && Number.isFinite(startedAt) && startedAt > 0 diff --git a/packages/platform-apple/src/readiness/runtime.ts b/packages/platform-apple/src/readiness/runtime.ts index c8f41c18c1..e9b0877ef6 100644 --- a/packages/platform-apple/src/readiness/runtime.ts +++ b/packages/platform-apple/src/readiness/runtime.ts @@ -3,7 +3,8 @@ import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { emitRequestProgress } from '@agent-device/host-kit/request'; import { delegateManagedDeviceReadiness } from '@agent-device/provision-kit/managed-device-scope'; -import { getSimulatorState, simctlArgs } from '../simulator-state.ts'; +import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; +import { getSimulatorState } from '../simulator-state.ts'; /** Readiness reads exactly these host ports; the lifecycle binding composes the same subset. */ export type AppleReadinessHost = Pick< @@ -98,7 +99,7 @@ async function startSimulatorBoot( const boot = await host.appleTools.run( { tool: 'simctl', - args: simctlArgs(device, ['boot', device.id]), + args: scopeSimctlArgsForDevice(device, ['boot', device.id]), allowFailure: true, timeoutMs: remainingBootBudgetMs(deadlineAtMs, device), }, @@ -131,7 +132,7 @@ async function waitForSimulatorBoot( const status = await host.appleTools.run( { tool: 'simctl', - args: simctlArgs(device, ['bootstatus', device.id, '-b']), + args: scopeSimctlArgsForDevice(device, ['bootstatus', device.id, '-b']), allowFailure: true, timeoutMs: remainingBootBudgetMs(deadlineAtMs, device), }, @@ -194,6 +195,10 @@ async function showSimulator(host: AppleReadinessHost, signal: AbortSignal): Pro function scheduleSimulatorShutdown(host: AppleReadinessHost, device: DeviceInfo): void { void host.appleTools - .run({ tool: 'simctl', args: simctlArgs(device, ['shutdown', device.id]), allowFailure: true }) + .run({ + tool: 'simctl', + args: scopeSimctlArgsForDevice(device, ['shutdown', device.id]), + allowFailure: true, + }) .catch(() => {}); } diff --git a/packages/platform-apple/src/shutdown/runtime.ts b/packages/platform-apple/src/shutdown/runtime.ts index 3ee3a9a37f..d0e5931c5f 100644 --- a/packages/platform-apple/src/shutdown/runtime.ts +++ b/packages/platform-apple/src/shutdown/runtime.ts @@ -2,7 +2,8 @@ import type { DeviceShutdownRuntimeDependencies } from '@agent-device/contracts/ import type { TargetShutdownResult } from '@agent-device/contracts/device'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { normalizeError } from '@agent-device/kernel/errors'; -import { getSimulatorState, simctlArgs } from '../simulator-state.ts'; +import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; +import { getSimulatorState } from '../simulator-state.ts'; const SHUTDOWN_TIMEOUT_MS = 15_000; @@ -37,7 +38,7 @@ async function shutdownAppleTarget( const result = await appleTools.run( { tool: 'simctl', - args: simctlArgs(device, ['shutdown', device.id]), + args: scopeSimctlArgsForDevice(device, ['shutdown', device.id]), allowFailure: true, timeoutMs: SHUTDOWN_TIMEOUT_MS, }, diff --git a/packages/platform-apple/src/simulator-inventory.ts b/packages/platform-apple/src/simulator-inventory.ts index 3878add11e..80e80bf052 100644 --- a/packages/platform-apple/src/simulator-inventory.ts +++ b/packages/platform-apple/src/simulator-inventory.ts @@ -5,6 +5,7 @@ import type { } from '@agent-device/contracts/platform-runtime-host'; import { sortAppleDevicesForSelection, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { scopeSimctlArgs } from './core/simctl.ts'; import { isSupportedAppleRuntime, resolveAppleOs, @@ -26,8 +27,7 @@ type SimctlListDevicesPayload = { const BOOTED_SIMULATOR_PROBE_TIMEOUT_MS = 3_000; export function buildSimctlListArgs(simulatorSetPath: string | undefined): string[] { - const path = simulatorSetPath?.trim(); - return path ? ['--set', path, 'list', 'devices', '-j'] : ['list', 'devices', '-j']; + return scopeSimctlArgs(['list', 'devices', '-j'], { simulatorSetPath }); } export function parseSimctlAppleDevices( diff --git a/packages/platform-apple/src/simulator-state.ts b/packages/platform-apple/src/simulator-state.ts index 9f80f3614f..3b843cc073 100644 --- a/packages/platform-apple/src/simulator-state.ts +++ b/packages/platform-apple/src/simulator-state.ts @@ -1,5 +1,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AppleToolHost } from '@agent-device/contracts/platform-runtime-host'; +import { scopeSimctlArgsForDevice } from './core/simctl.ts'; +import { readSimctlDeviceState } from './core/simctl-device-list.ts'; export async function getSimulatorState( appleTools: AppleToolHost, @@ -10,27 +12,12 @@ export async function getSimulatorState( const result = await appleTools.run( { tool: 'simctl', - args: simctlArgs(device, ['list', 'devices', '-j']), + args: scopeSimctlArgsForDevice(device, ['list', 'devices', '-j']), allowFailure: true, timeoutMs, }, signal, ); if (result.exitCode !== 0) return null; - try { - const payload = JSON.parse(result.stdout) as { - devices?: Record>; - }; - return ( - Object.values(payload.devices ?? {}) - .flat() - .find(({ udid }) => udid === device.id)?.state ?? null - ); - } catch { - return null; - } -} - -export function simctlArgs(device: DeviceInfo, args: readonly string[]): string[] { - return device.simulatorSetPath ? ['--set', device.simulatorSetPath, ...args] : [...args]; + return readSimctlDeviceState(result.stdout, device.id); } diff --git a/packages/platform-apple/src/snapshot-target.ts b/packages/platform-apple/src/snapshot-target.ts index 18bce81b92..de8c1b1296 100644 --- a/packages/platform-apple/src/snapshot-target.ts +++ b/packages/platform-apple/src/snapshot-target.ts @@ -1,7 +1,8 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { createDetachedAttempts, waitForDetachedAttempt } from './detached-attempt.ts'; -import { runSimctl } from './core/apps-simctl.ts'; +import { runSimctlForDevice } from './core/simctl.ts'; +import { readSimctlDevicesByRuntime } from './core/simctl-device-list.ts'; import { readSnapshotTargetProcessStartTime } from './snapshot-process.ts'; /** Identity re-check of a cached target: one local `ps`, never CoreSimulator IPC. */ @@ -77,7 +78,7 @@ async function resolveSimulatorSnapshotTarget( // failure would release its single-flight slot while the other probe still runs, and every // capture after it would start a probe of its own. const [jobsProbe, runtimeProbe] = await Promise.allSettled([ - runSimctl(device, ['spawn', device.id, 'launchctl', 'list'], { + runSimctlForDevice(device, ['spawn', device.id, 'launchctl', 'list'], { allowFailure: true, timeoutMs: remainingMs(deadline), }), @@ -118,15 +119,12 @@ async function readSimulatorRuntime( ): Promise { const existing = runtimeByDevice.get(device.id); if (existing) return await existing; - const pending = runSimctl(device, ['list', 'devices', '-j'], { + const pending = runSimctlForDevice(device, ['list', 'devices', '-j'], { allowFailure: true, timeoutMs: remainingMs(deadline), }).then((result) => { if (result.exitCode !== 0) throw targetError('simulator-runtime-probe-failed', device, ''); - const payload = JSON.parse(result.stdout) as { - devices?: Record>; - }; - const runtime = Object.entries(payload.devices ?? {}).find(([, devices]) => + const runtime = Object.entries(readSimctlDevicesByRuntime(result.stdout)).find(([, devices]) => devices.some((candidate) => candidate.udid === device.id), )?.[0]; if (!runtime) throw targetError('simulator-runtime-unavailable', device, ''); From 47edaa927e46de4353be426dd5e2c962260c6ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 16:24:06 +0200 Subject: [PATCH 2/3] refactor(ios): fold the missing-app output check into its only caller apps-simctl.ts held nothing simctl-related once its runner alias was gone; the devicectl uninstall path is its only consumer. Also states the device-list reader's real throw contract. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/platform-apple/src/core/apps-simctl.ts | 7 ------- packages/platform-apple/src/core/physical-device-apps.ts | 9 ++++++++- packages/platform-apple/src/core/simctl-device-list.ts | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) delete mode 100644 packages/platform-apple/src/core/apps-simctl.ts diff --git a/packages/platform-apple/src/core/apps-simctl.ts b/packages/platform-apple/src/core/apps-simctl.ts deleted file mode 100644 index 54dd55f6d7..0000000000 --- a/packages/platform-apple/src/core/apps-simctl.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function isMissingAppErrorOutput(output: string): boolean { - return ( - output.includes('not installed') || - output.includes('not found') || - output.includes('no such file') - ); -} diff --git a/packages/platform-apple/src/core/physical-device-apps.ts b/packages/platform-apple/src/core/physical-device-apps.ts index 65cd80d2ad..256782d280 100644 --- a/packages/platform-apple/src/core/physical-device-apps.ts +++ b/packages/platform-apple/src/core/physical-device-apps.ts @@ -8,7 +8,6 @@ import { terminateIosDeviceApp, } from './devicectl.ts'; import type { IosAppInfo, IosDeviceAppProcesses } from './app-info.ts'; -import { isMissingAppErrorOutput } from './apps-simctl.ts'; export async function listCoreDeviceApps( device: DeviceInfo, @@ -61,3 +60,11 @@ export async function resolveCoreDeviceAppProcesses( ): Promise { return await resolveIosDeviceAppProcesses(device, bundleId); } + +function isMissingAppErrorOutput(output: string): boolean { + return ( + output.includes('not installed') || + output.includes('not found') || + output.includes('no such file') + ); +} diff --git a/packages/platform-apple/src/core/simctl-device-list.ts b/packages/platform-apple/src/core/simctl-device-list.ts index d398935960..848ca777e5 100644 --- a/packages/platform-apple/src/core/simctl-device-list.ts +++ b/packages/platform-apple/src/core/simctl-device-list.ts @@ -1,6 +1,6 @@ type SimctlListedDevice = { udid?: string; state?: string }; -/** The runtime-keyed device lists of `simctl list devices -j` output; throws on malformed JSON. */ +/** The runtime-keyed device lists of `simctl list devices -j` output; throws unless it is a JSON object. */ export function readSimctlDevicesByRuntime(stdout: string): Record { const payload = JSON.parse(stdout) as { devices?: Record }; return payload.devices ?? {}; From c8cb34e1e780bece72bcd59611ec9f897c733417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 17:37:20 +0200 Subject: [PATCH 3/3] fix(ios): keep the simctl device-list parser in the already-eager simctl module Extracting the shared simctl-list-devices parser into its own module added a new static edge to platform-apple/src/core/simulator.ts, tripping the simulator-facade eager-closure budget (24 -> 25). Fold the parser into core/simctl.ts, which simulator.ts already evaluates, instead of giving it a new file. --- .../core/__tests__/simctl-device-list.test.ts | 42 ------------------- .../src/core/__tests__/simctl.test.ts | 41 ++++++++++++++++++ .../src/core/simctl-device-list.ts | 20 --------- packages/platform-apple/src/core/simctl.ts | 21 ++++++++++ packages/platform-apple/src/core/simulator.ts | 3 +- .../platform-apple/src/simulator-state.ts | 3 +- .../platform-apple/src/snapshot-target.ts | 3 +- 7 files changed, 65 insertions(+), 68 deletions(-) delete mode 100644 packages/platform-apple/src/core/__tests__/simctl-device-list.test.ts delete mode 100644 packages/platform-apple/src/core/simctl-device-list.ts diff --git a/packages/platform-apple/src/core/__tests__/simctl-device-list.test.ts b/packages/platform-apple/src/core/__tests__/simctl-device-list.test.ts deleted file mode 100644 index d258bd64d9..0000000000 --- a/packages/platform-apple/src/core/__tests__/simctl-device-list.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { test } from 'vitest'; -import assert from 'node:assert/strict'; -import { readSimctlDevicesByRuntime, readSimctlDeviceState } from '../simctl-device-list.ts'; - -const LISTING = JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ - { udid: 'sim-a', state: 'Shutdown', name: 'iPhone 16' }, - ], - 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [ - { udid: 'sim-b', state: 'Booted', name: 'iPhone 17' }, - ], - }, -}); - -test('readSimctlDeviceState reports the listed state of the requested simulator', () => { - assert.equal(readSimctlDeviceState(LISTING, 'sim-b'), 'Booted'); - assert.equal(readSimctlDeviceState(LISTING, 'sim-a'), 'Shutdown'); -}); - -test('readSimctlDeviceState is null for an unlisted, unreadable, or empty listing', () => { - assert.equal(readSimctlDeviceState(LISTING, 'sim-missing'), null); - assert.equal(readSimctlDeviceState('not json', 'sim-b'), null); - assert.equal(readSimctlDeviceState('{}', 'sim-b'), null); - assert.equal(readSimctlDeviceState(JSON.stringify({ devices: { runtime: {} } }), 'sim-b'), null); -}); - -test('readSimctlDevicesByRuntime keys each device list by its runtime', () => { - const devicesByRuntime = readSimctlDevicesByRuntime(LISTING); - assert.deepEqual( - Object.entries(devicesByRuntime).map(([runtime, devices]) => [ - runtime, - devices.map(({ udid }) => udid), - ]), - [ - ['com.apple.CoreSimulator.SimRuntime.iOS-18-0', ['sim-a']], - ['com.apple.CoreSimulator.SimRuntime.iOS-26-0', ['sim-b']], - ], - ); - assert.deepEqual(readSimctlDevicesByRuntime('{}'), {}); - assert.throws(() => readSimctlDevicesByRuntime('not json'), SyntaxError); -}); diff --git a/packages/platform-apple/src/core/__tests__/simctl.test.ts b/packages/platform-apple/src/core/__tests__/simctl.test.ts index 73a5477fef..1845a402c0 100644 --- a/packages/platform-apple/src/core/__tests__/simctl.test.ts +++ b/packages/platform-apple/src/core/__tests__/simctl.test.ts @@ -3,6 +3,8 @@ import assert from 'node:assert/strict'; import { buildSimctlArgs, buildSimctlArgsForDevice, + readSimctlDevicesByRuntime, + readSimctlDeviceState, scopeSimctlArgs, scopeSimctlArgsForDevice, } from '../simctl.ts'; @@ -82,3 +84,42 @@ test('scopeSimctlArgsForDevice scopes simulators only', () => { 'sim-1', ]); }); + +const LISTING = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: 'sim-a', state: 'Shutdown', name: 'iPhone 16' }, + ], + 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [ + { udid: 'sim-b', state: 'Booted', name: 'iPhone 17' }, + ], + }, +}); + +test('readSimctlDeviceState reports the listed state of the requested simulator', () => { + assert.equal(readSimctlDeviceState(LISTING, 'sim-b'), 'Booted'); + assert.equal(readSimctlDeviceState(LISTING, 'sim-a'), 'Shutdown'); +}); + +test('readSimctlDeviceState is null for an unlisted, unreadable, or empty listing', () => { + assert.equal(readSimctlDeviceState(LISTING, 'sim-missing'), null); + assert.equal(readSimctlDeviceState('not json', 'sim-b'), null); + assert.equal(readSimctlDeviceState('{}', 'sim-b'), null); + assert.equal(readSimctlDeviceState(JSON.stringify({ devices: { runtime: {} } }), 'sim-b'), null); +}); + +test('readSimctlDevicesByRuntime keys each device list by its runtime', () => { + const devicesByRuntime = readSimctlDevicesByRuntime(LISTING); + assert.deepEqual( + Object.entries(devicesByRuntime).map(([runtime, devices]) => [ + runtime, + devices.map(({ udid }) => udid), + ]), + [ + ['com.apple.CoreSimulator.SimRuntime.iOS-18-0', ['sim-a']], + ['com.apple.CoreSimulator.SimRuntime.iOS-26-0', ['sim-b']], + ], + ); + assert.deepEqual(readSimctlDevicesByRuntime('{}'), {}); + assert.throws(() => readSimctlDevicesByRuntime('not json'), SyntaxError); +}); diff --git a/packages/platform-apple/src/core/simctl-device-list.ts b/packages/platform-apple/src/core/simctl-device-list.ts deleted file mode 100644 index 848ca777e5..0000000000 --- a/packages/platform-apple/src/core/simctl-device-list.ts +++ /dev/null @@ -1,20 +0,0 @@ -type SimctlListedDevice = { udid?: string; state?: string }; - -/** The runtime-keyed device lists of `simctl list devices -j` output; throws unless it is a JSON object. */ -export function readSimctlDevicesByRuntime(stdout: string): Record { - const payload = JSON.parse(stdout) as { devices?: Record }; - return payload.devices ?? {}; -} - -/** The listed state of one simulator; null when the listing is unreadable or omits the device. */ -export function readSimctlDeviceState(stdout: string, udid: string): string | null { - try { - for (const devices of Object.values(readSimctlDevicesByRuntime(stdout))) { - const match = devices.find((entry) => entry.udid === udid); - if (match) return match.state ?? null; - } - return null; - } catch { - return null; - } -} diff --git a/packages/platform-apple/src/core/simctl.ts b/packages/platform-apple/src/core/simctl.ts index 8f83f0514a..b47011bcdd 100644 --- a/packages/platform-apple/src/core/simctl.ts +++ b/packages/platform-apple/src/core/simctl.ts @@ -41,3 +41,24 @@ export function runSimctlForDevice( ): Promise { return runXcrun(buildSimctlArgsForDevice(device, args), options); } + +type SimctlListedDevice = { udid?: string; state?: string }; + +/** The runtime-keyed device lists of `simctl list devices -j` output; throws unless it is a JSON object. */ +export function readSimctlDevicesByRuntime(stdout: string): Record { + const payload = JSON.parse(stdout) as { devices?: Record }; + return payload.devices ?? {}; +} + +/** The listed state of one simulator; null when the listing is unreadable or omits the device. */ +export function readSimctlDeviceState(stdout: string, udid: string): string | null { + try { + for (const devices of Object.values(readSimctlDevicesByRuntime(stdout))) { + const match = devices.find((entry) => entry.udid === udid); + if (match) return match.state ?? null; + } + return null; + } catch { + return null; + } +} diff --git a/packages/platform-apple/src/core/simulator.ts b/packages/platform-apple/src/core/simulator.ts index 254eab3b98..0bcbb73f21 100644 --- a/packages/platform-apple/src/core/simulator.ts +++ b/packages/platform-apple/src/core/simulator.ts @@ -12,8 +12,7 @@ import { IOS_SIMCTL_LIST_TIMEOUT_MS, IOS_SIMULATOR_FOCUS_TIMEOUT_MS, } from './config.ts'; -import { buildSimctlArgsForDevice } from './simctl.ts'; -import { readSimctlDeviceState } from './simctl-device-list.ts'; +import { buildSimctlArgsForDevice, readSimctlDeviceState } from './simctl.ts'; import { runAppleToolCommand, runXcrun } from './tool-provider.ts'; const IOS_SIMULATOR_HOST_APPS = ['Simulator'] as const; diff --git a/packages/platform-apple/src/simulator-state.ts b/packages/platform-apple/src/simulator-state.ts index 3b843cc073..df6235deaa 100644 --- a/packages/platform-apple/src/simulator-state.ts +++ b/packages/platform-apple/src/simulator-state.ts @@ -1,7 +1,6 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AppleToolHost } from '@agent-device/contracts/platform-runtime-host'; -import { scopeSimctlArgsForDevice } from './core/simctl.ts'; -import { readSimctlDeviceState } from './core/simctl-device-list.ts'; +import { readSimctlDeviceState, scopeSimctlArgsForDevice } from './core/simctl.ts'; export async function getSimulatorState( appleTools: AppleToolHost, diff --git a/packages/platform-apple/src/snapshot-target.ts b/packages/platform-apple/src/snapshot-target.ts index de8c1b1296..3f3cb2755e 100644 --- a/packages/platform-apple/src/snapshot-target.ts +++ b/packages/platform-apple/src/snapshot-target.ts @@ -1,8 +1,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { createDetachedAttempts, waitForDetachedAttempt } from './detached-attempt.ts'; -import { runSimctlForDevice } from './core/simctl.ts'; -import { readSimctlDevicesByRuntime } from './core/simctl-device-list.ts'; +import { readSimctlDevicesByRuntime, runSimctlForDevice } from './core/simctl.ts'; import { readSnapshotTargetProcessStartTime } from './snapshot-process.ts'; /** Identity re-check of a cached target: one local `ps`, never CoreSimulator IPC. */