From c95637222c3666a2068b00bcb856363a9a16c7a2 Mon Sep 17 00:00:00 2001 From: Muvaffak Onus Date: Mon, 2 Nov 2026 18:33:20 +0300 Subject: [PATCH 1/3] refactor(apple): share simulator files apps and runner operations --- .../src/__tests__/fake-apple-tool.ts | 6 +- .../interactor-runner-provider.test.ts | 70 ++++- .../platform-apple/src/app-state-runtime.ts | 4 +- .../app-resolution-deep-link.test.ts | 8 +- .../src/core/__tests__/app-resolution.test.ts | 38 ++- .../core/__tests__/display-inventory.test.ts | 6 +- .../src/core/__tests__/screenshot.test.ts | 28 +- .../platform-apple/src/core/app-device-io.ts | 2 +- .../platform-apple/src/core/app-resolution.ts | 253 ++++-------------- .../platform-apple/src/core/app-settings.ts | 45 ++-- .../src/core/local-simulator-apps.test.ts | 41 +++ .../src/core/local-simulator-apps.ts | 148 ++++++++++ .../src/core/local-simulator-files.ts | 106 ++++++++ .../src/core/screenshot-status-bar.ts | 13 +- .../platform-apple/src/core/screenshot.ts | 195 +++++--------- .../src/core/settings-text-size.ts | 2 +- .../src/core/simctl-execution.ts | 12 + packages/platform-apple/src/core/simctl.ts | 22 +- .../src/core/simulator-app-budget.ts | 12 + .../src/core/simulator-files.test.ts | 165 ++++++++++++ .../src/core/simulator-files.ts | 8 + .../src/core/tool-provider-types.ts | 58 +++- .../platform-apple/src/core/tool-provider.ts | 101 ++++--- .../src/deployment/runtime.test.ts | 65 +++++ .../platform-apple/src/deployment/runtime.ts | 39 ++- .../src/foldable/fold-helper.ts | 2 +- packages/platform-apple/src/interactor.ts | 90 +++++-- .../src/launch-confirmation.test.ts | 36 ++- .../platform-apple/src/launch-confirmation.ts | 31 ++- packages/platform-apple/src/lifecycle.ts | 77 ++++-- .../src/runner-interaction-facts.ts | 142 ++++++++++ .../src/runner-operations-facade.ts | 8 + .../platform-apple/src/runner-runtime.test.ts | 82 ++++++ packages/platform-apple/src/runner-runtime.ts | 134 ++++++++++ .../platform-apple/src/runtime-snapshot.ts | 13 +- packages/platform-apple/src/runtime.ts | 153 ++--------- .../platform-apple/src/snapshot-target.ts | 2 +- .../src/tool-provider-facade.ts | 5 + src/platform-runtime-apple-tool-host.test.ts | 4 +- .../provider-scenarios/providers.ts | 5 +- 40 files changed, 1552 insertions(+), 679 deletions(-) create mode 100644 packages/platform-apple/src/core/local-simulator-apps.test.ts create mode 100644 packages/platform-apple/src/core/local-simulator-apps.ts create mode 100644 packages/platform-apple/src/core/local-simulator-files.ts create mode 100644 packages/platform-apple/src/core/simctl-execution.ts create mode 100644 packages/platform-apple/src/core/simulator-app-budget.ts create mode 100644 packages/platform-apple/src/core/simulator-files.test.ts create mode 100644 packages/platform-apple/src/core/simulator-files.ts create mode 100644 packages/platform-apple/src/runner-interaction-facts.ts create mode 100644 packages/platform-apple/src/runner-runtime.test.ts create mode 100644 packages/platform-apple/src/runner-runtime.ts diff --git a/packages/platform-apple/src/__tests__/fake-apple-tool.ts b/packages/platform-apple/src/__tests__/fake-apple-tool.ts index 81a63ba6df..7400aa72f0 100644 --- a/packages/platform-apple/src/__tests__/fake-apple-tool.ts +++ b/packages/platform-apple/src/__tests__/fake-apple-tool.ts @@ -1,7 +1,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { AppleToolProvider } from '../core/tool-provider.ts'; -import { withAppleToolProvider } from '../core/tool-provider.ts'; +import { withAppleToolProvider, createLocalAppleToolProvider } from '../core/tool-provider.ts'; import { execFailureDetails, type ExecResult } from '@agent-device/host-kit/command'; import { IOS_DEVICE } from './device-fixtures.ts'; @@ -38,7 +38,7 @@ export async function withFakeAppleTool( return result; }; - const provider: AppleToolProvider = { + const provider = createLocalAppleToolProvider({ ...options.provider, whichCommand: options.provider?.whichCommand ?? (async () => true), runCommand: async (cmd, args, execOptions) => @@ -51,7 +51,7 @@ export async function withFakeAppleTool( run: async (args, execOptions) => await respond(['devicectl', ...args], 'xcrun', execOptions?.allowFailure), }, - }; + }); return await withAppleToolProvider(provider, async () => await run({ calls, device })); } diff --git a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts index 7889ab7d88..e5988872d6 100644 --- a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts @@ -11,6 +11,7 @@ import { test } from 'vitest'; import { IOS_SIMULATOR, MACOS_DEVICE } from './device-fixtures.ts'; import type { AppleRunnerProvider } from '@agent-device/apple-runner'; import { createAppleInteractor } from '../interactor.ts'; +import { createAppleRunnerRuntime } from '../runner-runtime.ts'; import { recordingRunnerProvider, runnerResultFor, @@ -119,19 +120,44 @@ test('the runner/local partition covers the full provider-backed interactor surf assert.deepEqual(Object.keys(interactor).sort(), classified); }); -test('provider-backed interactor routes runner-command methods through the injected transport', async () => { - const calls: RecordedRunnerCall[] = []; - const interactor = createAppleInteractor( +test('provider runtime binding preserves every shared runner interaction command and result', async () => { + const localCalls: RecordedRunnerCall[] = []; + const remoteCommands: Record[] = []; + const context = { appBundleId: 'com.example.app', requestId: 'http-parity' }; + const host = createLiveTestHostEnvironment(); + const local = createAppleInteractor( IOS_SIMULATOR, - { appBundleId: 'com.example.app' }, - createLiveTestHostEnvironment(), - recordingRunnerProvider(calls), + context, + host, + recordingRunnerProvider(localCalls), ); + const http = createAppleRunnerRuntime({ + device: IOS_SIMULATOR, + host, + provider: { + hasLiveSession: () => true, + runCommand: async (_device, command) => { + remoteCommands.push(JSON.parse(JSON.stringify(command))); + return runnerResultFor(command); + }, + }, + }); + const remote = http.createInteractor(context); for (const [method, { invoke, runnerCommand }] of Object.entries(RUNNER_TRANSPORT_METHODS)) { - calls.length = 0; - await invoke(interactor); - assert.ok(calls.length >= 1, `${method} never reached the injected runner transport`); - assert.equal(calls[0]!.command.command, runnerCommand, `${method} sent a different command`); + localCalls.length = 0; + remoteCommands.length = 0; + const expected = await invoke(local); + const actual = await invoke(remote); + assert.ok(localCalls.length >= 1, `${method} never reached the runner`); + assert.equal(localCalls[0]!.command.command, runnerCommand); + assert.deepEqual( + remoteCommands.map(({ commandId: _id, ...command }) => command), + JSON.parse( + JSON.stringify(localCalls.map(({ command: { commandId: _id, ...command } }) => command)), + ), + method, + ); + assert.deepEqual(actual, expected, method); } }); @@ -492,3 +518,27 @@ test('snapshot forwards either forceable preferredBackend into the emitted runne assert.equal(snapshots[0]?.command.preferredBackend, 'tree'); assert.equal(snapshots[1]?.command.preferredBackend, undefined); }); + +test('app-scoped reads inherit the bound target and preserve explicit overrides', async () => { + const calls: RecordedRunnerCall[] = []; + const interactor = createAppleInteractor( + IOS_SIMULATOR, + { appBundleId: 'com.example.bound' }, + createLiveTestHostEnvironment(), + recordingRunnerProvider(calls), + ); + const reads = [ + (appBundleId?: string) => interactor.findText!('Ready', { appBundleId }), + (appBundleId?: string) => interactor.readTextAtPoint!({ x: 10, y: 20 }, { appBundleId }), + (appBundleId?: string) => interactor.readAlert!({ appBundleId }), + (appBundleId?: string) => interactor.awaitAlert!({ appBundleId }), + (appBundleId?: string) => interactor.acceptAlert!({ appBundleId }), + (appBundleId?: string) => interactor.dismissAlert!({ appBundleId }), + ]; + for (const read of reads) { + await read(); + assert.equal(calls.at(-1)?.command.appBundleId, 'com.example.bound'); + await read('com.example.override'); + assert.equal(calls.at(-1)?.command.appBundleId, 'com.example.override'); + } +}); diff --git a/packages/platform-apple/src/app-state-runtime.ts b/packages/platform-apple/src/app-state-runtime.ts index 97f14860a4..db371e915d 100644 --- a/packages/platform-apple/src/app-state-runtime.ts +++ b/packages/platform-apple/src/app-state-runtime.ts @@ -16,7 +16,9 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; * the simulator host in the runner-demand table, since it demands no runner of its own. */ export function bindAppleAppStateRuntime( - host: Pick, + host: { + appleApplications: Pick; + }, params: Readonly<{ device: DeviceInfo; signal: AbortSignal; diff --git a/packages/platform-apple/src/core/__tests__/app-resolution-deep-link.test.ts b/packages/platform-apple/src/core/__tests__/app-resolution-deep-link.test.ts index 10aa23c5b7..bcfd56e3b8 100644 --- a/packages/platform-apple/src/core/__tests__/app-resolution-deep-link.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-resolution-deep-link.test.ts @@ -53,15 +53,17 @@ const SCHEME_PROBES = [ function createSchemeTool( fixture: SchemeFixture, - options: { failFirstProbes?: number } = {}, + options: { failFirstAppAProbe?: boolean } = {}, ): { handle: (args: string[]) => FakeAppleToolResponse; probeCount: () => number } { let probeCount = 0; + let failedAppA = false; const respondToPlutil = (plistPath: string): FakeAppleToolResponse => { const probe = SCHEME_PROBES.find((candidate) => plistPath.endsWith(candidate.suffix)); if (!probe) return '{}'; probeCount += 1; - if (options.failFirstProbes !== undefined && probeCount <= options.failFirstProbes) { + if (options.failFirstAppAProbe && plistPath === fixture.plistA && !failedAppA) { + failedAppA = true; return { stdout: '', exitCode: 1 }; } return probe.schemesJson; @@ -97,7 +99,7 @@ test('resolveIosSimulatorDeepLinkBundleId memoizes per-app url-scheme probes', a test('a failed url-scheme probe is retried on the next lookup instead of cached', async () => { const fixture = await makeSchemeFixture(); - const tool = createSchemeTool(fixture, { failFirstProbes: 1 }); + const tool = createSchemeTool(fixture, { failFirstAppAProbe: true }); await withFakeAppleTool(tool.handle, async () => { const duringFailure = await resolveIosSimulatorDeepLinkBundleId( 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 1e093f1079..c3ba95b894 100644 --- a/packages/platform-apple/src/core/__tests__/app-resolution.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-resolution.test.ts @@ -1,11 +1,17 @@ import assert from 'node:assert/strict'; -import { beforeEach, test, vi } from 'vitest'; +import { afterEach, beforeEach, test, vi } from 'vitest'; -const { mockRunSimctl } = vi.hoisted(() => ({ mockRunSimctl: vi.fn() })); +const { mockRunSimctl, commandShapes } = vi.hoisted(() => ({ + mockRunSimctl: vi.fn(), + commandShapes: vi.fn(), +})); -vi.mock('../simctl.ts', async (importOriginal) => ({ - ...(await importOriginal()), - runSimctlForDevice: mockRunSimctl, +vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ + ...(await importOriginal()), + runCmd: async (cmd: string, args: string[], options: unknown) => { + commandShapes(cmd, args); + return { exitCode: 0, stderr: '', ...(await mockRunSimctl(args.slice(1), options)) }; + }, })); import { @@ -24,7 +30,15 @@ const bootedSimulator: DeviceInfo = { booted: true, }; +afterEach(() => { + for (const [cmd, args] of commandShapes.mock.calls) { + assert.equal(cmd, 'xcrun'); + assert.equal(args[0], 'simctl'); + } +}); + beforeEach(() => { + commandShapes.mockClear(); mockRunSimctl.mockReset(); mockRunSimctl.mockResolvedValue({ stdout: JSON.stringify({ @@ -127,7 +141,7 @@ test('findIosSimulatorInstalledApp does not probe a stopped simulator', async () }); function mockLaunchctlAndListapps(runningBundleIds: string[]): void { - mockRunSimctl.mockImplementation(async (_device: DeviceInfo, args: string[]) => { + mockRunSimctl.mockImplementation(async (args: string[]) => { if (args[0] === 'spawn') { const lines = runningBundleIds.map( (bundleId, index) => `${1000 + index}\t0\tUIKitApplication:${bundleId}[abcd][rb-legacy]`, @@ -173,7 +187,7 @@ test('detectSoleRunningIosSimulatorApp returns undefined when nothing is running }); test('detectSoleRunningIosSimulatorApp treats a failed probe as inconclusive', async () => { - mockRunSimctl.mockImplementation(async (_device: DeviceInfo, args: string[]) => { + mockRunSimctl.mockImplementation(async (args: string[]) => { if (args[0] === 'spawn') return { exitCode: 1, stdout: '' }; return { stdout: JSON.stringify({ 'com.apple.Preferences': { CFBundleDisplayName: 'Settings' } }), @@ -184,7 +198,7 @@ test('detectSoleRunningIosSimulatorApp treats a failed probe as inconclusive', a }); test('detectSoleRunningIosSimulatorApp catches a rejecting launchctl probe (timeout/spawn failure)', async () => { - mockRunSimctl.mockImplementation(async (_device: DeviceInfo, args: string[]) => { + mockRunSimctl.mockImplementation(async (args: string[]) => { if (args[0] === 'spawn') throw new Error('xcrun timed out after 3000ms'); return { stdout: JSON.stringify({ 'com.apple.Preferences': { CFBundleDisplayName: 'Settings' } }), @@ -195,7 +209,7 @@ test('detectSoleRunningIosSimulatorApp catches a rejecting launchctl probe (time }); test('detectSoleRunningIosSimulatorApp catches a rejecting listapps probe (timeout/spawn failure)', async () => { - mockRunSimctl.mockImplementation(async (_device: DeviceInfo, args: string[]) => { + mockRunSimctl.mockImplementation(async (args: string[]) => { if (args[0] === 'spawn') { return { exitCode: 0, @@ -214,9 +228,9 @@ test('detectSoleRunningIosSimulatorApp bounds the listapps probe with a timeout' await detectSoleRunningIosSimulatorApp(bootedSimulator); - const listappsCall = mockRunSimctl.mock.calls.find(([, args]) => args[0] === 'listapps'); - assert.equal(typeof listappsCall?.[2]?.timeoutMs, 'number'); - assert.ok((listappsCall?.[2]?.timeoutMs ?? 0) > 0); + const listappsCall = mockRunSimctl.mock.calls.find(([args]) => args[0] === 'listapps'); + assert.equal(typeof listappsCall?.[1]?.timeoutMs, 'number'); + assert.ok((listappsCall?.[1]?.timeoutMs ?? 0) > 0); }); test('detectSoleRunningIosSimulatorApp does not probe a stopped simulator', async () => { diff --git a/packages/platform-apple/src/core/__tests__/display-inventory.test.ts b/packages/platform-apple/src/core/__tests__/display-inventory.test.ts index b2b0c612d0..69a1576ece 100644 --- a/packages/platform-apple/src/core/__tests__/display-inventory.test.ts +++ b/packages/platform-apple/src/core/__tests__/display-inventory.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, test, vi } from 'vitest'; import assert from 'node:assert/strict'; -vi.mock('../simctl.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../simctl-execution.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, runSimctlForDevice: vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })), @@ -49,7 +49,7 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { runXcrun } from '../tool-provider.ts'; import { readHostTextFile } from '@agent-device/host-kit/host-file'; -import { runSimctlForDevice } from '../simctl.ts'; +import { runSimctlForDevice } from '../simctl-execution.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { ensureFoldHelperBinary } from '../../foldable/fold-helper-cache.ts'; import { IOS_TEST_SIMULATOR } from './apple-core-stub-helpers.ts'; diff --git a/packages/platform-apple/src/core/__tests__/screenshot.test.ts b/packages/platform-apple/src/core/__tests__/screenshot.test.ts index 398eff074d..35da996370 100644 --- a/packages/platform-apple/src/core/__tests__/screenshot.test.ts +++ b/packages/platform-apple/src/core/__tests__/screenshot.test.ts @@ -50,7 +50,7 @@ import { captureSimulatorScreenshotWithRetry, captureScreenshotViaRunner, screenshotIos, - resolveSimulatorRunnerScreenshotCandidatePaths, + resolveRunnerScreenshotFilePaths, shouldRetryIosSimulatorScreenshot, } from '../screenshot.ts'; import { ensureBootedSimulator, openIosSimulatorApp } from '../simulator.ts'; @@ -491,28 +491,18 @@ test('captureSimulatorScreenshotWithFallback uses simulator runner fallback by d } }); -test('resolveSimulatorRunnerScreenshotCandidatePaths includes tmp-based and basename fallbacks', () => { - const containerPath = '/tmp/container'; - const candidates = resolveSimulatorRunnerScreenshotCandidatePaths( - containerPath, - '/var/mobile/Containers/Data/Application/abc/tmp/screenshot-1.png', - ); - assert.equal(candidates.includes(path.join(containerPath, 'tmp', 'screenshot-1.png')), true); - assert.equal( - candidates.includes('/var/mobile/Containers/Data/Application/abc/tmp/screenshot-1.png'), - true, - ); +test('resolveRunnerScreenshotFilePaths preserves the runner path and handles legacy absolute paths', () => { + assert.deepEqual(resolveRunnerScreenshotFilePaths('/private/var/mobile/tmp/fallback.png'), [ + '/private/var/mobile/tmp/fallback.png', + 'tmp/fallback.png', + ]); + assert.deepEqual(resolveRunnerScreenshotFilePaths('tmp/current.png'), ['tmp/current.png']); }); -test('resolveSimulatorRunnerScreenshotCandidatePaths handles empty runner path', () => { - assert.deepEqual(resolveSimulatorRunnerScreenshotCandidatePaths('/tmp/container', ' '), []); +test('resolveRunnerScreenshotFilePaths handles an empty runner path', () => { + assert.deepEqual(resolveRunnerScreenshotFilePaths(' '), []); }); -// Production `simctl` answers an installed app that owns no data container with exit 0 and the -// literal `(null)` on stdout. The runner container loop reads that as the tool's "no container" -// answer — the same rule clear-app-state refuses on — and never hands the sentinel to the host -// filesystem as a path. Without the shared classifier, the thrown wrap below carries a copy ENOENT -// from a candidate path built on `(null)` instead. test('captureScreenshotViaRunner treats a (null) data-container answer as no container', async () => { const tmpDir = await mkdtempForTest('agent-device-runner-null-container-'); const device = { ...IOS_TEST_SIMULATOR, id: 'sim-runner-null-container' }; diff --git a/packages/platform-apple/src/core/app-device-io.ts b/packages/platform-apple/src/core/app-device-io.ts index 6ac4a959a0..6f4e6535a7 100644 --- a/packages/platform-apple/src/core/app-device-io.ts +++ b/packages/platform-apple/src/core/app-device-io.ts @@ -2,7 +2,7 @@ import { isMacOs, isTvOsDevice, type DeviceInfo } from '@agent-device/kernel/dev import { requireExecSuccess } from '@agent-device/host-kit/command'; import { ensureBootedSimulator, requireSimulatorDevice } from './simulator.ts'; import { readMacOsClipboardText, writeMacOsClipboardText } from '../os/macos/apps.ts'; -import { runSimctlForDevice } from './simctl.ts'; +import { runSimctlForDevice } from './simctl-execution.ts'; import { runAppleRunnerCommand } from './runner-client.ts'; import type { AppleRunnerCommandOptions } from '@agent-device/apple-runner'; diff --git a/packages/platform-apple/src/core/app-resolution.ts b/packages/platform-apple/src/core/app-resolution.ts index 79e1779bef..e1e30e1db1 100644 --- a/packages/platform-apple/src/core/app-resolution.ts +++ b/packages/platform-apple/src/core/app-resolution.ts @@ -1,9 +1,6 @@ -import path from 'node:path'; -import { hostFileStat } from '@agent-device/host-kit/host-file'; -import { fileURLToPath } from 'node:url'; +import type { SimulatorAppListOptions, SimulatorAppMetadata } from './tool-provider-types.ts'; import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; -import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors'; -import { isCommandTimeoutError } from '@agent-device/host-kit/command'; +import { AppError } from '@agent-device/kernel/errors'; import type { AppsFilter } from '@agent-device/contracts/device'; import { createAppResolutionCache, @@ -13,11 +10,8 @@ import type { IosAppInfo } from './app-info.ts'; 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 { readSimctlContainerPath, runSimctlForDevice } from './simctl.ts'; +import { resolveAppleToolProvider } from './tool-provider.ts'; import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; -import { createTtlMemo } from '@agent-device/kernel/ttl-memo'; -import { Deadline } from '@agent-device/host-kit/retry'; const ALIASES: Record = { settings: 'com.apple.Preferences', @@ -187,6 +181,7 @@ export async function detectSoleRunningIosSimulatorApp( } async function listRunningIosSimulatorBundleIds(device: DeviceInfo): Promise { + const { runSimctlForDevice } = await import('./simctl-execution.ts'); const result = await runSimctlForDevice(device, ['spawn', device.id, 'launchctl', 'list'], { allowFailure: true, timeoutMs: IOS_FOREGROUND_APP_PROBE_TIMEOUT_MS, @@ -201,13 +196,6 @@ async function listRunningIosSimulatorBundleIds(device: DeviceInfo): Promise !app.bundleId.startsWith(AGENT_DEVICE_RUNNER_BUNDLE_PREFIX), + ); + const claims = await Promise.all( + candidates.map(async (app) => ({ + app, + schemes: await readInstalledAppUrlSchemes(device, app.bundleId, remaining(), app.path), + })), + ); + const matches = claims.filter(({ schemes }) => schemes.has(scheme)).map(({ app }) => app); const userMatches = matches.filter((app) => app.applicationType === 'User'); if (userMatches.length === 1) return userMatches[0]?.bundleId; if (userMatches.length > 1) return undefined; @@ -252,18 +238,9 @@ export async function resolveIosSimulatorExpoDevClientScheme( options: SimulatorAppListOptions = {}, ): Promise { if (!isIosFamily(device) || device.kind !== 'simulator') return undefined; + const { remainingLookupBudget } = await import('./simulator-app-budget.ts'); const remaining = remainingLookupBudget(options); - const container = await runSimctlForDevice( - device, - ['get_app_container', device.id, bundleId, 'app'], - { allowFailure: true, ...remaining() }, - ); - const appPath = readSimctlContainerPath(String(container.stdout)); - if (container.exitCode !== 0 || !appPath) return undefined; - const schemes = await readIosSimulatorAppUrlSchemes( - path.join(appPath, 'Info.plist'), - remaining(), - ); + const schemes = await readInstalledAppUrlSchemes(device, bundleId, remaining()); const devClientSchemes = [...schemes].filter( (scheme) => scheme.startsWith(EXPO_DEV_CLIENT_SCHEME_PREFIX) && @@ -275,14 +252,32 @@ export async function resolveIosSimulatorExpoDevClientScheme( return owner === bundleId ? scheme : undefined; } -function remainingLookupBudget({ - timeoutMs, - signal, -}: SimulatorAppListOptions): () => SimulatorAppListOptions { - const abortable = signal ? { signal } : {}; - if (timeoutMs === undefined) return () => abortable; - const deadline = Deadline.fromTimeoutMs(timeoutMs); - return () => ({ timeoutMs: Math.max(1, deadline.remainingMs()), ...abortable }); +async function readInstalledAppUrlSchemes( + device: DeviceInfo, + bundleId: string, + options: SimulatorAppListOptions, + appPath?: string, +): Promise> { + const plist = await resolveAppleToolProvider().simulatorApps.readInfoPlist({ + device, + bundleId, + appPath, + ...options, + }); + return urlSchemesFromPlist(plist); +} + +function urlSchemesFromPlist(plist: Record | null): Set { + const schemes = new Set(); + const types = plist?.CFBundleURLTypes; + if (!Array.isArray(types)) return schemes; + for (const type of types) { + if (!Array.isArray(type?.CFBundleURLSchemes)) continue; + for (const value of type.CFBundleURLSchemes) { + if (typeof value === 'string' && value.trim()) schemes.add(value.trim().toLowerCase()); + } + } + return schemes; } function parseUrlScheme(url: string): string | undefined { @@ -301,12 +296,6 @@ export async function listIosApps(device: DeviceInfo, filter: AppsFilter): Promi return await resolveIosPhysicalDeviceControl(device).listApps(device, filter); } -type SimulatorAppListOptions = Readonly<{ - /** Unset (default) preserves prior unbounded behavior for normal command flow. */ - timeoutMs?: number; - signal?: AbortSignal; -}>; - async function listSimulatorApps( device: DeviceInfo, options?: SimulatorAppListOptions, @@ -326,7 +315,7 @@ const IOS_APP_LIST_UNREADABLE_REASON = 'app-list-unreadable'; * failure of its own, never an empty list: reading it as one would report an installed app missing. */ async function requireSimulatorApps(device: DeviceInfo): Promise { - const read = await readSimulatorAppMetadata(device); + const read = await resolveAppleToolProvider().simulatorApps.list(device); if (read.type === 'unreadable') { throw new AppError('COMMAND_FAILED', `Could not list the apps installed on ${device.id}`, { reason: IOS_APP_LIST_UNREADABLE_REASON, @@ -339,162 +328,10 @@ async function requireSimulatorApps(device: DeviceInfo): Promise { return read.apps.map((app) => ({ bundleId: app.bundleId, name: app.name })); } -type SimulatorAppListRecord = Record< - string, - { - ApplicationType?: string; - Bundle?: string; - CFBundleDisplayName?: string; - CFBundleName?: string; - Path?: string; - } ->; - async function listSimulatorAppMetadata( device: DeviceInfo, options?: SimulatorAppListOptions, ): Promise { - const read = await readSimulatorAppMetadata(device, options); + const read = await resolveAppleToolProvider().simulatorApps.list(device, options); return read.type === 'listed' ? read.apps : []; } - -type SimulatorAppListRead = - | Readonly<{ type: 'listed'; apps: SimulatorAppMetadata[] }> - | Readonly<{ type: 'unreadable'; exitCode: number | null | undefined; stderr: string }>; - -async function readSimulatorAppMetadata( - device: DeviceInfo, - options?: SimulatorAppListOptions, -): Promise { - const remaining = remainingLookupBudget(options ?? {}); - const result = await runSimctlForDevice(device, ['listapps', device.id], { - allowFailure: true, - ...remaining(), - }); - const unreadable = { - type: 'unreadable', - exitCode: result.exitCode, - stderr: String(result.stderr ?? ''), - } as const; - const trimmed = (result.stdout as string).trim(); - if (!trimmed.startsWith('{')) return unreadable; - - const parsed = - parseSimulatorAppList(trimmed) ?? (await convertSimulatorAppList(trimmed, remaining())); - if (!parsed) return unreadable; - const apps = Object.entries(parsed).map(([bundleId, info]) => { - const appPath = resolveSimulatorAppPath(info); - return { - bundleId, - name: info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId, - ...(appPath ? { path: appPath } : {}), - ...(info.ApplicationType ? { applicationType: info.ApplicationType } : {}), - }; - }); - return { type: 'listed', apps }; -} - -function parseSimulatorAppList(text: string): SimulatorAppListRecord | null { - try { - return JSON.parse(text) as SimulatorAppListRecord; - } catch { - return null; - } -} - -/** - * `simctl listapps` prints an OpenStep plist on some Xcode versions; `plutil` converts it. A - * timeout or abort of that conversion is the lookup's failure, not an empty app list. - */ -async function convertSimulatorAppList( - text: string, - options: SimulatorAppListOptions, -): Promise { - try { - const converted = await runAppleToolCommand('plutil', ['-convert', 'json', '-o', '-', '-'], { - allowFailure: true, - stdin: text, - ...options, - }); - if (converted.exitCode !== 0 || !converted.stdout.trim().startsWith('{')) return null; - return JSON.parse(converted.stdout) as SimulatorAppListRecord; - } catch (error) { - if (isCommandTimeoutError(error) || isRequestCanceledError(error)) throw error; - return null; - } -} - -function resolveSimulatorAppPath(info: { Bundle?: string; Path?: string }): string | undefined { - if (info.Path) return info.Path; - if (!info.Bundle) return undefined; - try { - return fileURLToPath(info.Bundle); - } catch { - return undefined; - } -} - -// One plutil spawn per installed app per deep-link lookup dominates open -// scheme:// latency; schemes only change when the bundle is reinstalled, so -// the mtime-keyed memo absorbs the per-lookup cost. Only successful parses are -// cacheable — a transient probe failure must not pin empty schemes until TTL -// expiry — and expiry is scheduled so abandoned install paths do not -// accumulate in a long-lived daemon. -const IOS_SIMULATOR_APP_URL_SCHEME_MEMO_TTL_MS = 30 * 60_000; -const iosSimulatorAppUrlSchemeMemo = createTtlMemo>({ - ttlMs: IOS_SIMULATOR_APP_URL_SCHEME_MEMO_TTL_MS, - scheduleExpiry: true, -}); - -async function readIosSimulatorAppUrlSchemes( - infoPlistPath: string, - options: SimulatorAppListOptions, -): Promise> { - let cacheKey: string | undefined; - try { - cacheKey = `${infoPlistPath}:${(await hostFileStat(infoPlistPath)).mtimeMs}`; - } catch { - cacheKey = undefined; - } - if (cacheKey === undefined) { - return (await readIosSimulatorAppUrlSchemesUncached(infoPlistPath, options)) ?? new Set(); - } - const cached = iosSimulatorAppUrlSchemeMemo.get(cacheKey); - if (cached) return cached; - const schemes = await readIosSimulatorAppUrlSchemesUncached(infoPlistPath, options); - if (schemes) iosSimulatorAppUrlSchemeMemo.set(cacheKey, schemes); - return schemes ?? new Set(); -} - -/** - * Returns the bundle's URL schemes, or `undefined` when the probe failed - * (nonzero exit or malformed output) and the result must not be cached. - */ -async function readIosSimulatorAppUrlSchemesUncached( - infoPlistPath: string, - options: SimulatorAppListOptions, -): Promise | undefined> { - const result = await runAppleToolCommand( - 'plutil', - ['-convert', 'json', '-o', '-', infoPlistPath], - { allowFailure: true, ...options }, - ); - if (result.exitCode !== 0) return undefined; - try { - const parsed = JSON.parse(result.stdout) as { - CFBundleURLTypes?: Array<{ CFBundleURLSchemes?: unknown }>; - }; - const schemes = new Set(); - for (const urlType of parsed.CFBundleURLTypes ?? []) { - if (!Array.isArray(urlType.CFBundleURLSchemes)) continue; - for (const scheme of urlType.CFBundleURLSchemes) { - if (typeof scheme === 'string' && scheme.trim()) { - schemes.add(scheme.trim().toLowerCase()); - } - } - } - return schemes; - } catch { - return undefined; - } -} diff --git a/packages/platform-apple/src/core/app-settings.ts b/packages/platform-apple/src/core/app-settings.ts index 7e97f5ed80..aa68c5a0e3 100644 --- a/packages/platform-apple/src/core/app-settings.ts +++ b/packages/platform-apple/src/core/app-settings.ts @@ -18,20 +18,14 @@ import { summarizeCommandAttemptFailures, type CommandAttemptFailure, } from '@agent-device/kernel/errors'; -import { - ensureHostDirectory, - readHostDirectory, - removeHostPath, -} from '@agent-device/host-kit/host-file'; -import path from 'node:path'; -import { requireExecSuccess } from '@agent-device/host-kit/command'; import { setMacOsAppearance } from '../os/macos/apps.ts'; import { runMacOsPermissionAction, type MacOsPermissionTarget } from '../os/macos/helper.ts'; import { closeIosApp } from './app-launch.ts'; import { applySimctlSetting } from './simctl-settings.ts'; import { readIosTextSize, setIosTextSize } from './settings-text-size.ts'; import { resolveIosApp } from './app-resolution.ts'; -import { buildSimctlArgsForDevice, readSimctlContainerPath, runSimctlForDevice } from './simctl.ts'; +import { buildSimctlArgsForDevice } from './simctl.ts'; +import { runSimctlForDevice } from './simctl-execution.ts'; import { invalidateSimulatorStatusBarOverrideCache, rememberClearedStatusBarOverrides, @@ -39,7 +33,7 @@ import { withSimulatorStatusBarHeld, } from './screenshot-status-bar.ts'; import { ensureBootedSimulator, requireSimulatorDevice } from './simulator.ts'; -import { runXcrun } from './tool-provider.ts'; +import { resolveAppleToolProvider, runXcrun } from './tool-provider.ts'; const MACOS_HOST_SETTINGS = ['appearance', 'permission'] as const satisfies readonly SettingName[]; @@ -294,7 +288,7 @@ async function clearIosSimulatorAppState( device: DeviceInfo, app: string, env: Readonly, -): Promise<{ bundleId: string; containerPath: string }> { +): Promise<{ bundleId: string; containerPath?: string }> { if (!isIosFamily(device) || device.kind !== 'simulator') { throw new AppError( 'UNSUPPORTED_OPERATION', @@ -306,18 +300,13 @@ async function clearIosSimulatorAppState( await ensureBootedSimulator(device); await closeIosApp(device, bundleId, undefined, env); - const result = requireExecSuccess( - await runSimctlForDevice(device, ['get_app_container', device.id, bundleId, 'data'], { - allowFailure: true, - }), - `simctl get_app_container failed for ${bundleId}`, - ); - - // `simctl` answers an app with no data container by exiting 0 and printing nothing or the literal - // `(null)` — a system app is installed and running with nothing to delete. Both are the tool's own - // "no container" answer, refused here rather than handed to the host filesystem as a path. - const containerPath = readSimctlContainerPath(result.stdout); - if (containerPath === undefined) { + const container = await resolveAppleToolProvider().simulatorFiles.openContainer({ + device, + bundleId, + containerType: 'data', + timeoutMs: 15_000, + }); + if (!container) { throw new AppError('UNSUPPORTED_OPERATION', iosNoDataContainerMessage(bundleId), { reason: IOS_NO_DATA_CONTAINER_REASON, appBundleId: bundleId, @@ -325,20 +314,16 @@ async function clearIosSimulatorAppState( hint: IOS_NO_DATA_CONTAINER_HINT, }); } - - const entries = await readHostDirectory(containerPath); + const entries = await container.list(); await Promise.all( entries .filter((entry) => entry !== CONTAINER_MANAGER_METADATA_FILE) - .map((entry) => removeHostPath(path.join(containerPath, entry))), + .map((entry) => container.remove(entry)), ); await Promise.all( - FRESH_INSTALL_DATA_DIRECTORIES.map((directory) => - ensureHostDirectory(path.join(containerPath, directory)), - ), + FRESH_INSTALL_DATA_DIRECTORIES.map((directory) => container.ensureDirectory(directory)), ); - - return { bundleId, containerPath }; + return { bundleId, ...(container.path ? { containerPath: container.path } : {}) }; } function parseMacOsPermissionTarget(value: string | undefined): MacOsPermissionTarget { diff --git a/packages/platform-apple/src/core/local-simulator-apps.test.ts b/packages/platform-apple/src/core/local-simulator-apps.test.ts new file mode 100644 index 0000000000..db5d807896 --- /dev/null +++ b/packages/platform-apple/src/core/local-simulator-apps.test.ts @@ -0,0 +1,41 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import { mkdtempForTest } from '@agent-device/test-kit'; +import { IOS_TEST_SIMULATOR } from './__tests__/apple-core-stub-helpers.ts'; +import { readSimulatorAppInfoPlist } from './local-simulator-apps.ts'; + +test('app metadata shares one timeout between container lookup and plist conversion', async () => { + const appPath = await mkdtempForTest('simulator-app-info-budget-'); + await fs.writeFile(path.join(appPath, 'Info.plist'), ''); + let now = Date.now(); + const clock = vi.spyOn(Date, 'now').mockImplementation(() => now); + const signal = new AbortController().signal; + const convert = vi.fn(async () => ({ + stdout: JSON.stringify({ CFBundleIdentifier: 'com.example.app' }), + stderr: '', + exitCode: 0, + })); + try { + const plist = await readSimulatorAppInfoPlist( + { + simctl: { + run: async () => { + now += 80; + return { stdout: appPath, stderr: '', exitCode: 0 }; + }, + }, + runCommand: convert, + }, + { device: IOS_TEST_SIMULATOR, bundleId: 'com.example.app', timeoutMs: 100, signal }, + ); + expect(plist?.CFBundleIdentifier).toBe('com.example.app'); + expect(convert).toHaveBeenCalledWith( + 'plutil', + ['-convert', 'json', '-o', '-', path.join(appPath, 'Info.plist')], + { allowFailure: true, timeoutMs: 20, signal }, + ); + } finally { + clock.mockRestore(); + } +}); diff --git a/packages/platform-apple/src/core/local-simulator-apps.ts b/packages/platform-apple/src/core/local-simulator-apps.ts new file mode 100644 index 0000000000..1ef62b65fb --- /dev/null +++ b/packages/platform-apple/src/core/local-simulator-apps.ts @@ -0,0 +1,148 @@ +import { readSimctlContainerPath, scopeSimctlArgsForDevice } from './simctl.ts'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { hostFileStat } from '@agent-device/host-kit/host-file'; +import { isCommandTimeoutError } from '@agent-device/host-kit/command'; +import { isRequestCanceledError } from '@agent-device/kernel/errors'; +import { createTtlMemo } from '@agent-device/kernel/ttl-memo'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { remainingLookupBudget } from './simulator-app-budget.ts'; +import type { + AppleSimulatorAppInfoRequest, + AppleSimctlToolProvider, + AppleToolCommandExecutor, + SimulatorAppListOptions, + SimulatorAppListRead, +} from './tool-provider-types.ts'; + +type SimulatorAppListRecord = Record< + string, + { + ApplicationType?: string; + Bundle?: string; + CFBundleDisplayName?: string; + CFBundleName?: string; + Path?: string; + } +>; + +export async function readSimulatorAppMetadata( + tools: { simctl: AppleSimctlToolProvider; runCommand: AppleToolCommandExecutor }, + device: DeviceInfo, + options?: SimulatorAppListOptions, +): Promise { + const remaining = remainingLookupBudget(options ?? {}); + const result = await tools.simctl.run(scopeSimctlArgsForDevice(device, ['listapps', device.id]), { + allowFailure: true, + ...remaining(), + }); + const unreadable = { + type: 'unreadable', + exitCode: result.exitCode, + stderr: String(result.stderr ?? ''), + } as const; + const trimmed = (result.stdout as string).trim(); + if (!trimmed.startsWith('{')) return unreadable; + + const parsed = + parseSimulatorAppList(trimmed) ?? + (await convertSimulatorAppList(tools.runCommand, trimmed, remaining())); + if (!parsed) return unreadable; + const apps = Object.entries(parsed).map(([bundleId, info]) => { + const appPath = resolveSimulatorAppPath(info); + return { + bundleId, + name: info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId, + ...(appPath ? { path: appPath } : {}), + ...(info.ApplicationType ? { applicationType: info.ApplicationType } : {}), + }; + }); + return { type: 'listed', apps }; +} + +function parseSimulatorAppList(text: string): SimulatorAppListRecord | null { + try { + return JSON.parse(text) as SimulatorAppListRecord; + } catch { + return null; + } +} + +/** + * `simctl listapps` prints an OpenStep plist on some Xcode versions; `plutil` converts it. A + * timeout or abort of that conversion is the lookup's failure, not an empty app list. + */ +async function convertSimulatorAppList( + runCommand: AppleToolCommandExecutor, + text: string, + options: SimulatorAppListOptions, +): Promise { + try { + const converted = await runCommand('plutil', ['-convert', 'json', '-o', '-', '-'], { + allowFailure: true, + stdin: text, + ...options, + }); + if (converted.exitCode !== 0 || !converted.stdout.trim().startsWith('{')) return null; + return JSON.parse(converted.stdout) as SimulatorAppListRecord; + } catch (error) { + if (isCommandTimeoutError(error) || isRequestCanceledError(error)) throw error; + return null; + } +} + +function resolveSimulatorAppPath(info: { Bundle?: string; Path?: string }): string | undefined { + if (info.Path) return info.Path; + if (!info.Bundle) return undefined; + try { + return fileURLToPath(info.Bundle); + } catch { + return undefined; + } +} + +// Cache only successful parses; reinstalling changes the bundle's plist mtime. +const infoPlistMemo = createTtlMemo>({ + ttlMs: 30 * 60_000, + scheduleExpiry: true, +}); + +export async function readSimulatorAppInfoPlist( + { simctl, runCommand }: { simctl: AppleSimctlToolProvider; runCommand: AppleToolCommandExecutor }, + { device, bundleId, appPath, ...options }: AppleSimulatorAppInfoRequest, +): Promise | null> { + const remaining = remainingLookupBudget(options); + if (!appPath) { + const container = await simctl.run( + scopeSimctlArgsForDevice(device, ['get_app_container', device.id, bundleId, 'app']), + { + allowFailure: true, + ...remaining(), + }, + ); + appPath = readSimctlContainerPath(String(container.stdout)); + if (container.exitCode !== 0 || !appPath) return null; + } + const infoPlistPath = path.join(appPath, 'Info.plist'); + let cacheKey: string | undefined; + try { + cacheKey = `${infoPlistPath}:${(await hostFileStat(infoPlistPath)).mtimeMs}`; + } catch { + cacheKey = undefined; + } + options.signal?.throwIfAborted(); + const cached = cacheKey === undefined ? undefined : infoPlistMemo.get(cacheKey); + if (cached) return cached; + const result = await runCommand('plutil', ['-convert', 'json', '-o', '-', infoPlistPath], { + allowFailure: true, + ...remaining(), + }); + if (result.exitCode !== 0) return null; + try { + const plist = JSON.parse(result.stdout) as Record; + if (cacheKey !== undefined && plist) infoPlistMemo.set(cacheKey, plist); + return plist; + } catch { + return null; + } +} diff --git a/packages/platform-apple/src/core/local-simulator-files.ts b/packages/platform-apple/src/core/local-simulator-files.ts new file mode 100644 index 0000000000..27bcb4dfa5 --- /dev/null +++ b/packages/platform-apple/src/core/local-simulator-files.ts @@ -0,0 +1,106 @@ +import { readSimctlContainerPath, scopeSimctlArgsForDevice } from './simctl.ts'; +import path from 'node:path'; +import { + copyHostFile, + ensureHostDirectory, + hostFileStat, + readHostDirectory, + removeHostPath, +} from '@agent-device/host-kit/host-file'; +import { AppError } from '@agent-device/kernel/errors'; +import { requireExecSuccess } from '@agent-device/host-kit/command'; +import type { + AppleSimulatorFileCopy, + AppleSimctlToolProvider, + AppleSimulatorContainerRequest, + AppleSimulatorContainer, +} from './tool-provider-types.ts'; + +const containerPaths = new Map(); + +export async function copySimulatorFileToHost( + simctl: AppleSimctlToolProvider, + request: AppleSimulatorFileCopy, +): Promise { + request.signal?.throwIfAborted(); + if (path.isAbsolute(request.sourcePath)) { + await copyFromContainer('', request); + return; + } + const key = JSON.stringify([request.device.id, request.bundleId, request.containerType]); + const cached = containerPaths.get(key); + if (cached) { + try { + await copyFromContainer(cached, request); + return; + } catch (error) { + request.signal?.throwIfAborted(); + const exists = await hostFileStat(cached).then( + () => true, + (statError: NodeJS.ErrnoException) => { + if (statError.code === 'ENOENT') return false; + throw error; + }, + ); + if (exists) throw error; + containerPaths.delete(key); + } + } + const result = await simctl.run( + scopeSimctlArgsForDevice(request.device, [ + 'get_app_container', + request.device.id, + request.bundleId, + request.containerType, + ]), + { timeoutMs: request.timeoutMs, signal: request.signal }, + ); + const containerPath = readSimctlContainerPath(result.stdout); + if (!containerPath) { + throw new AppError( + 'COMMAND_FAILED', + `simctl get_app_container returned no ${request.containerType} container path`, + ); + } + containerPaths.set(key, containerPath); + await copyFromContainer(containerPath, request); +} + +async function copyFromContainer( + containerPath: string, + request: AppleSimulatorFileCopy, +): Promise { + request.signal?.throwIfAborted(); + const sourcePath = path.isAbsolute(request.sourcePath) + ? request.sourcePath + : path.join(containerPath, request.sourcePath); + await ensureHostDirectory(path.dirname(request.destinationPath)); + await copyHostFile(sourcePath, request.destinationPath); +} + +export async function openSimulatorContainer( + simctl: AppleSimctlToolProvider, + request: AppleSimulatorContainerRequest, +): Promise { + const { device, bundleId, containerType, timeoutMs, signal } = request; + signal?.throwIfAborted(); + const result = requireExecSuccess( + await simctl.run( + scopeSimctlArgsForDevice(device, ['get_app_container', device.id, bundleId, containerType]), + { + allowFailure: true, + timeoutMs, + signal, + }, + ), + `simctl get_app_container failed for ${bundleId}`, + ); + const containerPath = readSimctlContainerPath(result.stdout); + if (containerPath === undefined) return null; + return { + path: containerPath, + list: () => readHostDirectory(containerPath), + remove: (relativePath) => removeHostPath(path.join(containerPath, relativePath)), + ensureDirectory: (relativePath) => ensureHostDirectory(path.join(containerPath, relativePath)), + }; +} diff --git a/packages/platform-apple/src/core/screenshot-status-bar.ts b/packages/platform-apple/src/core/screenshot-status-bar.ts index 2a1729f730..3de7369723 100644 --- a/packages/platform-apple/src/core/screenshot-status-bar.ts +++ b/packages/platform-apple/src/core/screenshot-status-bar.ts @@ -4,7 +4,7 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { AppError } from '@agent-device/kernel/errors'; import { heldKeyedLockKeys, withKeyedLock } from '@agent-device/kernel/keyed-lock'; -import { runSimctlForDevice } from './simctl.ts'; +import { runSimctlForDevice } from './simctl-execution.ts'; import { extractAppleToolErrorMeta } from './tool-diagnostics.ts'; type RestorableStatusBarOverrides = Partial< @@ -217,6 +217,7 @@ async function readSimulatorStatusBarOverrides( await runSimctlForDevice(device, ['status_bar', device.id, 'list'], { allowFailure: true, timeoutMs: STATUS_BAR_COMMAND_TIMEOUT_MS, + requestCancellation: 'ignore', }), 'Failed to read simulator status bar overrides', ); @@ -227,7 +228,10 @@ async function clearSimulatorStatusBarOverride( device: DeviceInfo, timeoutMs = STATUS_BAR_COMMAND_TIMEOUT_MS, ): Promise { - await runSimctlForDevice(device, ['status_bar', device.id, 'clear'], { timeoutMs }); + await runSimctlForDevice(device, ['status_bar', device.id, 'clear'], { + timeoutMs, + requestCancellation: 'ignore', + }); } async function applySimulatorStatusBarOverrideArgs( @@ -236,7 +240,10 @@ async function applySimulatorStatusBarOverrideArgs( timeoutMs = STATUS_BAR_COMMAND_TIMEOUT_MS, ): Promise { if (args.length === 0) return; - await runSimctlForDevice(device, ['status_bar', device.id, 'override', ...args], { timeoutMs }); + await runSimctlForDevice(device, ['status_bar', device.id, 'override', ...args], { + timeoutMs, + requestCancellation: 'ignore', + }); } 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 40369a0702..8dcdc9a275 100644 --- a/packages/platform-apple/src/core/screenshot.ts +++ b/packages/platform-apple/src/core/screenshot.ts @@ -6,6 +6,8 @@ import { appleScreenshotCaptureCapabilities } from '../screenshot-support.ts'; import type { EnvMap } from '@agent-device/kernel/source-value'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { copyHostFile } from '@agent-device/host-kit/host-file'; +import { copySimulatorFileToHost } from './simulator-files.ts'; +import { resolveAppleToolProvider } from './tool-provider.ts'; import { Deadline, retryWithPolicy } from '@agent-device/host-kit/retry'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -41,7 +43,7 @@ import { type AppleDeviceDisplay, } from './display-inventory.ts'; import { ensureBootedSimulator } from './simulator.ts'; -import { readSimctlContainerPath, runSimctlForDevice } from './simctl.ts'; +import { runSimctlForDevice } from './simctl-execution.ts'; import { appleToolFailureText, extractAppleToolErrorMeta } from './tool-diagnostics.ts'; import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; @@ -121,7 +123,7 @@ const defaultSimulatorScreenshotFlowDeps: SimulatorScreenshotFlowDeps = { }; const iosSimulatorMainScreenScaleCache = new Map(); -const iosSimulatorRunnerContainerCache = new Map(); +const iosSimulatorRunnerBundleCache = new Map(); export async function screenshotIos( device: DeviceInfo, @@ -180,33 +182,36 @@ export async function captureSimulatorScreenshotWithFallback( await deps.normalizeDensity(device, outPath, options.pixelDensity, display?.pointScale); }; const captureWithFallback = async (): Promise => { - try { - await captureAndNormalize(); - return captureFacts; - } catch (error) { - let screenshotError = error; - if ( - options.skipIosSimulatorBootCheck && - shouldEnsureBootedAfterSimulatorScreenshotFailure(error) - ) { - await deps.ensureBooted(device); - try { - await captureAndNormalize(); - return captureFacts; - } catch (retryError) { - screenshotError = retryError; + if (resolveAppleToolProvider().simulatorScreenshotBackend !== 'runner') { + try { + await captureAndNormalize(); + return captureFacts; + } catch (error) { + let screenshotError = error; + if ( + options.skipIosSimulatorBootCheck && + shouldEnsureBootedAfterSimulatorScreenshotFailure(error) + ) { + await deps.ensureBooted(device); + try { + await captureAndNormalize(); + return captureFacts; + } catch (retryError) { + screenshotError = retryError; + } } + if (options.screenMask !== undefined || !deps.shouldFallbackToRunner(screenshotError)) { + throw screenshotError; + } + emitScreenshotFallbackDiagnostic(device, 'simctl_screenshot', screenshotError); } - if (options.screenMask !== undefined || !deps.shouldFallbackToRunner(screenshotError)) { - throw screenshotError; - } - emitScreenshotFallbackDiagnostic(device, 'simctl_screenshot', screenshotError); } const captured = await deps.captureWithRunner( device, outPath, options.appBundleId, - options.fullscreen, + options.fullscreen ?? + (resolveAppleToolProvider().simulatorScreenshotBackend === 'runner' ? true : undefined), options.runnerOptions, options.env, ); @@ -349,7 +354,13 @@ async function captureRunnerScreenshot( } if (device.kind === 'simulator') { - await copyRunnerScreenshotFromSimulator(device, remoteFileName, outPath, env); + await copyRunnerScreenshotFromSimulator( + device, + remoteFileName, + outPath, + env, + runnerOptions?.signal, + ); return metadata; } throw new AppError('COMMAND_FAILED', 'Unsupported Apple screenshot target'); @@ -360,73 +371,50 @@ async function copyRunnerScreenshotFromSimulator( remoteFileName: string, outPath: string, env: Readonly, + signal?: AbortSignal, ): Promise { const deadline = Deadline.fromTimeoutMs(IOS_RUNNER_SCREENSHOT_COPY_TIMEOUT_MS); let lastError = 'Unable to locate runner container for simulator screenshot'; - const cachedContainerPath = iosSimulatorRunnerContainerCache.get(device.id); - if (cachedContainerPath) { - const cachedCopy = await tryCopySimulatorRunnerScreenshot( - cachedContainerPath, - remoteFileName, - outPath, - ); - if (cachedCopy.copied) return; - lastError = cachedCopy.error; - iosSimulatorRunnerContainerCache.delete(device.id); - } - for (const bundleId of resolveRunnerContainerBundleIds(env)) { - const containerResult = await runSimctlForDevice( - device, - ['get_app_container', device.id, bundleId, 'data'], - { - allowFailure: true, - timeoutMs: resolveDeadlineTimeoutMs( - deadline, - IOS_RUNNER_SCREENSHOT_COPY_TIMEOUT_MS, - 'runner screenshot container lookup', - ), - }, - ); - if (containerResult.exitCode !== 0) { - const stderr = containerResult.stderr.trim(); - if (stderr) { - lastError = stderr; + const cachedBundleId = iosSimulatorRunnerBundleCache.get(device.id); + const bundleIds = resolveRunnerContainerBundleIds(env); + if (cachedBundleId) bundleIds.unshift(cachedBundleId); + for (const bundleId of new Set(bundleIds)) { + for (const sourcePath of resolveRunnerScreenshotFilePaths(remoteFileName)) { + try { + await copySimulatorFileToHost({ + device, + bundleId, + containerType: 'data', + signal, + sourcePath, + destinationPath: outPath, + timeoutMs: resolveDeadlineTimeoutMs( + deadline, + IOS_RUNNER_SCREENSHOT_COPY_TIMEOUT_MS, + 'runner screenshot copy', + ), + }); + iosSimulatorRunnerBundleCache.set(device.id, bundleId); + return; + } catch (error) { + signal?.throwIfAborted(); + lastError = error instanceof Error ? error.message : String(error); } - continue; - } - const containerPath = readSimctlContainerPath(containerResult.stdout); - if (containerPath === undefined) { - lastError = 'simctl get_app_container returned no data container path'; - continue; } - const copy = await tryCopySimulatorRunnerScreenshot(containerPath, remoteFileName, outPath); - if (copy.copied) { - iosSimulatorRunnerContainerCache.set(device.id, containerPath); - return; - } - lastError = copy.error; } + iosSimulatorRunnerBundleCache.delete(device.id); throw new AppError('COMMAND_FAILED', `Failed to capture iOS screenshot: ${lastError}`); } -async function tryCopySimulatorRunnerScreenshot( - containerPath: string, - remoteFileName: string, - outPath: string, -): Promise<{ copied: true } | { copied: false; error: string }> { - let lastError = 'Runner screenshot was not found in the simulator container'; - for (const sourcePath of resolveSimulatorRunnerScreenshotCandidatePaths( - containerPath, - remoteFileName, - )) { - try { - await copyHostFile(sourcePath, outPath); - return { copied: true }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - return { copied: false, error: lastError }; +export function resolveRunnerScreenshotFilePaths(remoteFileName: string): string[] { + const rawPath = remoteFileName.trim(); + if (!rawPath) return []; + const paths = new Set([rawPath]); + const posixPath = rawPath.replaceAll('\\', '/'); + const tmpIndex = posixPath.lastIndexOf('/tmp/'); + if (tmpIndex >= 0) paths.add(posixPath.slice(tmpIndex + 1)); + paths.add(`tmp/${path.posix.basename(posixPath)}`); + return [...paths]; } function resolveDeadlineTimeoutMs(deadline: Deadline, timeoutMs: number, step: string): number { @@ -458,51 +446,6 @@ function emitScreenshotFallbackDiagnostic( }); } -export function resolveSimulatorRunnerScreenshotCandidatePaths( - containerPath: string, - remoteFileName: string, -): string[] { - const normalizedContainerPath = path.resolve(containerPath); - const rawRemotePath = remoteFileName.trim(); - if (!rawRemotePath) return []; - - const candidates: string[] = []; - const seen = new Set(); - const pushUnique = (candidate: string) => { - const normalized = path.normalize(candidate); - if (seen.has(normalized)) return; - seen.add(normalized); - candidates.push(normalized); - }; - - const relativeFromRoot = rawRemotePath.replace(/^\/+/, ''); - const remotePosixPath = relativeFromRoot.replaceAll('\\', '/'); - if (relativeFromRoot) { - pushUnique(path.join(normalizedContainerPath, relativeFromRoot)); - } - - if (path.isAbsolute(rawRemotePath)) { - pushUnique(path.normalize(rawRemotePath)); - } - - if (remotePosixPath.startsWith('tmp/')) { - pushUnique(path.join(normalizedContainerPath, remotePosixPath)); - } else { - const tmpSegmentIndex = remotePosixPath.lastIndexOf('/tmp/'); - if (tmpSegmentIndex >= 0) { - const fromTmp = remotePosixPath.slice(tmpSegmentIndex + 1); - pushUnique(path.join(normalizedContainerPath, fromTmp)); - } - } - - const baseName = path.basename(rawRemotePath); - if (baseName) { - pushUnique(path.join(normalizedContainerPath, 'tmp', baseName)); - } - - return candidates; -} - export function shouldRetryIosSimulatorScreenshot(error: unknown): boolean { if (!(error instanceof AppError)) return false; if (error.code !== 'COMMAND_FAILED') return false; diff --git a/packages/platform-apple/src/core/settings-text-size.ts b/packages/platform-apple/src/core/settings-text-size.ts index 3fcb7b3871..d028e8e5fa 100644 --- a/packages/platform-apple/src/core/settings-text-size.ts +++ b/packages/platform-apple/src/core/settings-text-size.ts @@ -7,7 +7,7 @@ import { import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { requireExecSuccess } from '@agent-device/host-kit/command'; -import { runSimctlForDevice } from './simctl.ts'; +import { runSimctlForDevice } from './simctl-execution.ts'; import { ensureBootedSimulator } from './simulator.ts'; /** `simctl ui content_size` reads and writes the ladder's names verbatim. */ diff --git a/packages/platform-apple/src/core/simctl-execution.ts b/packages/platform-apple/src/core/simctl-execution.ts new file mode 100644 index 0000000000..4dbe88a5de --- /dev/null +++ b/packages/platform-apple/src/core/simctl-execution.ts @@ -0,0 +1,12 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { ExecResult } from '@agent-device/host-kit/command'; +import { runXcrun, type AppleToolExecOptions } from './tool-provider.ts'; +import { buildSimctlArgsForDevice } from './simctl.ts'; + +export function runSimctlForDevice( + device: DeviceInfo, + args: readonly string[], + options?: AppleToolExecOptions, +): Promise { + return runXcrun(buildSimctlArgsForDevice(device, args), options); +} diff --git a/packages/platform-apple/src/core/simctl.ts b/packages/platform-apple/src/core/simctl.ts index db5e65485f..eedef950c4 100644 --- a/packages/platform-apple/src/core/simctl.ts +++ b/packages/platform-apple/src/core/simctl.ts @@ -1,8 +1,9 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; -import { type ExecOptions, type ExecResult } from '@agent-device/host-kit/command'; import { resolveIosSimulatorDeviceSetPath } from '@agent-device/kernel/device-isolation'; -import type { ScopedSimctlArgs } from '@agent-device/contracts/platform-runtime-host'; -import { runXcrun, simctlCommand, type ScopedSimctlCommand } from './tool-provider.ts'; +import type { + ScopedSimctlArgs, + ScopedSimctlCommand, +} from '@agent-device/contracts/platform-runtime-host'; declare const simulatorAddress: unique symbol; /** @@ -55,7 +56,10 @@ export function buildSimctlArgsForAddress( address: SimulatorAddress, args: readonly string[], ): ScopedSimctlCommand { - return simctlCommand(scopeSimctlArgs(address.simulatorSetPath, args)); + return Object.freeze([ + 'simctl', + ...scopeSimctlArgs(address.simulatorSetPath, args), + ]) as ScopedSimctlCommand; } export function buildSimctlArgsForDevice( @@ -65,17 +69,9 @@ export function buildSimctlArgsForDevice( return buildSimctlArgsForAddress(simulatorAddressFor(device), args); } -export function runSimctlForDevice( - device: DeviceInfo, - args: readonly string[], - options?: ExecOptions, -): 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. */ +/** Parses runtime-keyed device lists from `simctl list devices -j` output. */ export function readSimctlDevicesByRuntime(stdout: string): Record { const payload = JSON.parse(stdout) as { devices?: Record }; return payload.devices ?? {}; diff --git a/packages/platform-apple/src/core/simulator-app-budget.ts b/packages/platform-apple/src/core/simulator-app-budget.ts new file mode 100644 index 0000000000..4a11d4dcce --- /dev/null +++ b/packages/platform-apple/src/core/simulator-app-budget.ts @@ -0,0 +1,12 @@ +import { Deadline } from '@agent-device/host-kit/retry'; +import type { SimulatorAppListOptions } from './tool-provider-types.ts'; + +export function remainingLookupBudget({ + timeoutMs, + signal, +}: SimulatorAppListOptions): () => SimulatorAppListOptions { + const abortable = signal ? { signal } : {}; + if (timeoutMs === undefined) return () => abortable; + const deadline = Deadline.fromTimeoutMs(timeoutMs); + return () => ({ timeoutMs: Math.max(1, deadline.remainingMs()), ...abortable }); +} diff --git a/packages/platform-apple/src/core/simulator-files.test.ts b/packages/platform-apple/src/core/simulator-files.test.ts new file mode 100644 index 0000000000..38fc7b1b5f --- /dev/null +++ b/packages/platform-apple/src/core/simulator-files.test.ts @@ -0,0 +1,165 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import { mkdtempForTest } from '@agent-device/test-kit'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { copySimulatorFileToHost } from './simulator-files.ts'; +import { createLocalAppleToolProvider, withAppleToolProvider } from './tool-provider.ts'; + +test.each(['app', 'data', 'group.com.example.shared'])( + 'local %s container copies preserve bytes and reuse the resolved container', + async (containerType) => { + const directory = await mkdtempForTest('simulator-files-'); + const container = path.join(directory, 'container'); + await fs.mkdir(path.join(container, 'Documents'), { recursive: true }); + const bytes = Buffer.from([0, 128, 255]); + await fs.writeFile(path.join(container, 'Documents', 'file.bin'), bytes); + const run = vi.fn(async () => ({ stdout: container, stderr: '', exitCode: 0 })); + const device: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + kind: 'simulator', + id: directory, + name: 'Simulator', + booted: true, + }; + const request = { + device, + bundleId: 'com.example.app', + containerType, + sourcePath: 'Documents/file.bin', + destinationPath: path.join(directory, 'out', 'file.bin'), + timeoutMs: 1000, + }; + await withAppleToolProvider(createLocalAppleToolProvider({ simctl: { run } }), async () => { + await expect( + copySimulatorFileToHost({ ...request, sourcePath: 'Documents/missing.bin' }), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await copySimulatorFileToHost(request); + await copySimulatorFileToHost(request); + expect(await fs.readFile(request.destinationPath)).toEqual(bytes); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith( + ['get_app_container', device.id, request.bundleId, containerType], + expect.objectContaining({ timeoutMs: 1000 }), + ); + await fs.mkdir(path.join(container, 'tmp')); + await fs.writeFile(path.join(container, 'tmp', 'missing.bin'), 'unrelated file'); + await expect( + copySimulatorFileToHost({ ...request, sourcePath: 'Documents/missing.bin' }), + ).rejects.toMatchObject({ code: 'ENOENT' }); + expect(run).toHaveBeenCalledTimes(1); + }); + }, +); + +test('a replaced app container invalidates its cached path', async () => { + const directory = await mkdtempForTest('simulator-files-replaced-'); + const oldContainer = path.join(directory, 'old'); + const newContainer = path.join(directory, 'new'); + await fs.mkdir(oldContainer); + await fs.mkdir(newContainer); + await fs.writeFile(path.join(oldContainer, 'file.bin'), 'old'); + await fs.writeFile(path.join(newContainer, 'file.bin'), 'new'); + const run = vi + .fn(async () => ({ stdout: newContainer, stderr: '', exitCode: 0 })) + .mockResolvedValueOnce({ stdout: oldContainer, stderr: '', exitCode: 0 }); + const device: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + kind: 'simulator', + id: directory, + name: 'Simulator', + booted: true, + }; + const request = { + device, + bundleId: 'com.example.app', + containerType: 'data', + sourcePath: 'file.bin', + destinationPath: path.join(directory, 'out.bin'), + timeoutMs: 1000, + }; + await withAppleToolProvider(createLocalAppleToolProvider({ simctl: { run } }), async () => { + await copySimulatorFileToHost(request); + expect(await fs.readFile(request.destinationPath, 'utf8')).toBe('old'); + await fs.rm(oldContainer, { recursive: true }); + await copySimulatorFileToHost(request); + expect(await fs.readFile(request.destinationPath, 'utf8')).toBe('new'); + expect(run).toHaveBeenCalledTimes(2); + }); +}); + +test('local inventory and container copies normalize loose host results before reading them', async () => { + const directory = await mkdtempForTest('simulator-loose-results-'); + await fs.writeFile(path.join(directory, 'file.bin'), 'container bytes'); + const device: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + kind: 'simulator', + id: directory, + name: 'Simulator', + booted: true, + }; + const provider = createLocalAppleToolProvider({ + simctl: { + run: async (args) => + ({ + exitCode: 0, + stdout: Buffer.from( + args[0] === 'listapps' + ? JSON.stringify({ 'com.example.app': { CFBundleDisplayName: 'Example' } }) + : directory, + ), + stderr: undefined, + }) as unknown as import('@agent-device/host-kit/command').ExecResult, + }, + }); + await withAppleToolProvider(provider, async () => { + const { listIosApps } = await import('./app-resolution.ts'); + expect(await listIosApps(device, 'all')).toEqual([ + { bundleId: 'com.example.app', name: 'Example' }, + ]); + const destinationPath = path.join(directory, 'copied.bin'); + await copySimulatorFileToHost({ + device, + bundleId: 'com.example.app', + containerType: 'data', + sourcePath: 'file.bin', + destinationPath, + timeoutMs: 1000, + }); + expect(await fs.readFile(destinationPath, 'utf8')).toBe('container bytes'); + }); +}); + +test('an absolute simulator screenshot path does not require an app container lookup', async () => { + const directory = await mkdtempForTest('simulator-absolute-file-'); + const sourcePath = path.join(directory, 'screenshot.png'); + const destinationPath = path.join(directory, 'output', 'screenshot.png'); + const bytes = Buffer.from([0, 128, 255]); + await fs.writeFile(sourcePath, bytes); + const run = vi.fn(async () => { + throw new Error('container lookup unavailable'); + }); + const device: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + kind: 'simulator', + id: directory, + name: 'Simulator', + booted: true, + }; + await withAppleToolProvider(createLocalAppleToolProvider({ simctl: { run } }), async () => { + await copySimulatorFileToHost({ + device, + bundleId: 'com.example.app', + containerType: 'data', + sourcePath, + destinationPath, + timeoutMs: 1000, + }); + }); + expect(await fs.readFile(destinationPath)).toEqual(bytes); + expect(run).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-apple/src/core/simulator-files.ts b/packages/platform-apple/src/core/simulator-files.ts new file mode 100644 index 0000000000..cbeedb8fac --- /dev/null +++ b/packages/platform-apple/src/core/simulator-files.ts @@ -0,0 +1,8 @@ +import { resolveAppleToolProvider } from './tool-provider.ts'; +import type { AppleSimulatorFileCopy } from './tool-provider-types.ts'; + +/** Copies a simulator container file through the active host's transport. */ +export async function copySimulatorFileToHost(request: AppleSimulatorFileCopy): Promise { + request.signal?.throwIfAborted(); + await resolveAppleToolProvider().simulatorFiles.copyToHost(request); +} diff --git a/packages/platform-apple/src/core/tool-provider-types.ts b/packages/platform-apple/src/core/tool-provider-types.ts index b7486bcb03..11acfd7da5 100644 --- a/packages/platform-apple/src/core/tool-provider-types.ts +++ b/packages/platform-apple/src/core/tool-provider-types.ts @@ -1,17 +1,22 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AppsFilter } from '@agent-device/contracts/device'; import type { ScopedSimctlArgs } from '@agent-device/contracts/platform-runtime-host'; import { type ExecOptions, type ExecResult } from '@agent-device/host-kit/command'; import type { IosAppInfo } from './app-info.ts'; +/** Cleanup ignores scoped request cancellation but keeps explicit signals and its own deadline. */ +export type AppleToolExecOptions = ExecOptions & + ({ requestCancellation?: undefined } | { requestCancellation: 'ignore'; timeoutMs: number }); + export type AppleToolCommandExecutor = ( cmd: string, args: string[], - options?: ExecOptions, + options?: AppleToolExecOptions, ) => Promise; export type AppleToolSubcommandExecutor = ( args: string[], - options?: ExecOptions, + options?: AppleToolExecOptions, ) => Promise; export type AppleToolAvailabilityChecker = (cmd: string) => Promise; @@ -21,7 +26,7 @@ export type AppleXcrunToolProvider = { }; export type AppleSimctlToolProvider = { - run: (args: ScopedSimctlArgs, options?: ExecOptions) => Promise; + run: (args: ScopedSimctlArgs, options?: AppleToolExecOptions) => Promise; }; export type AppleMacOsHelperProvider = { @@ -44,3 +49,50 @@ export type AppleMacOsHostProvider = { setDarkMode(enabled: boolean): Promise; listApps(filter: AppsFilter): Promise; }; + +export type AppleSimulatorFileCopy = { + device: DeviceInfo; + bundleId: string; + containerType: string; + sourcePath: string; + destinationPath: string; + timeoutMs: number; + signal?: AbortSignal; +}; + +export type AppleSimulatorContainerRequest = { + device: DeviceInfo; + bundleId: string; + containerType: string; + timeoutMs: number; + signal?: AbortSignal; +}; + +export type AppleSimulatorContainer = { + path?: string; + list(): Promise; + remove(relativePath: string): Promise; + ensureDirectory(relativePath: string): Promise; +}; + +export type SimulatorAppListOptions = Readonly<{ + timeoutMs?: number; + signal?: AbortSignal; +}>; + +export type SimulatorAppMetadata = { + bundleId: string; + name: string; + path?: string; + applicationType?: string; +}; + +export type SimulatorAppListRead = + | Readonly<{ type: 'listed'; apps: SimulatorAppMetadata[] }> + | Readonly<{ type: 'unreadable'; exitCode: number | null | undefined; stderr: string }>; + +export type AppleSimulatorAppInfoRequest = SimulatorAppListOptions & { + device: DeviceInfo; + bundleId: string; + appPath?: string; +}; diff --git a/packages/platform-apple/src/core/tool-provider.ts b/packages/platform-apple/src/core/tool-provider.ts index c04dddd765..ca2471863b 100644 --- a/packages/platform-apple/src/core/tool-provider.ts +++ b/packages/platform-apple/src/core/tool-provider.ts @@ -1,3 +1,4 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; import { coerceExecResult, runCmd, @@ -17,8 +18,15 @@ import type { AppleMacOsHostProvider, ApplePlistProvider, AppleSimctlToolProvider, + AppleSimulatorFileCopy, + AppleSimulatorContainerRequest, + AppleSimulatorContainer, + AppleSimulatorAppInfoRequest, + SimulatorAppListOptions, + SimulatorAppListRead, AppleToolAvailabilityChecker, AppleToolCommandExecutor, + AppleToolExecOptions, AppleXcrunToolProvider, } from './tool-provider-types.ts'; @@ -28,6 +36,7 @@ export type { ApplePlistProvider, AppleToolAvailabilityChecker, AppleToolCommandExecutor, + AppleToolExecOptions, AppleToolSubcommandExecutor, AppleXcrunToolProvider, } from './tool-provider-types.ts'; @@ -41,7 +50,7 @@ export function simctlCommand(args: ScopedSimctlArgs): ScopedSimctlCommand { async function runSimctlCommand( runCommand: AppleToolCommandExecutor, args: ScopedSimctlArgs, - options?: ExecOptions, + options?: AppleToolExecOptions, ): Promise { return await runCommand('xcrun', [...simctlCommand(args)], options); } @@ -57,62 +66,80 @@ export type AppleToolProvider = { macosHelper?: AppleMacOsHelperProvider; macosHost?: AppleMacOsHostProvider; plist?: ApplePlistProvider; + /** Hosts without a simctl screenshot transport use the runner's file capture. */ + simulatorScreenshotBackend?: 'runner'; + simulatorFiles: { + copyToHost(request: AppleSimulatorFileCopy): Promise; + openContainer(request: AppleSimulatorContainerRequest): Promise; + stageFile(path: string, signal?: AbortSignal): Promise; + }; + simulatorApps: { + list(device: DeviceInfo, options?: SimulatorAppListOptions): Promise; + readInfoPlist(request: AppleSimulatorAppInfoRequest): Promise | null>; + }; whichCommand: AppleToolAvailabilityChecker; }; export const APPLE_PLIST_TIMEOUT_MS = 15_000; -const localAppleToolProvider: AppleToolProvider = { - runCommand: runCmd, - simctl: { - run: async (args, options) => await runSimctlCommand(runCmd, args, options), - }, - devicectl: { - run: async (args, options) => await runCmd('xcrun', ['devicectl', ...args], options), - }, - plist: { - readJson: async (plistPath, signal) => - await readPlistJsonWithCommand(runCmd, plistPath, signal), - }, - macosHost: createLocalAppleMacOsHostProvider( - runCmd, - async (plistPath) => await readPlistJsonWithCommand(runCmd, plistPath), - ), - whichCommand: async (cmd) => await whichCmd(cmd, commandHostEnvironment().env), -}; +const localAppleToolProvider = createLocalAppleToolProvider(); const appleToolProviderScope = createScopedProvider( localAppleToolProvider, - normalizeAppleToolProvider, + createLocalAppleToolProvider, ); export function createLocalAppleToolProvider( provider: Partial = {}, ): AppleToolProvider { - const merged = { - ...localAppleToolProvider, - ...provider, + const runCommand = coerceRunCommand(provider.runCommand ?? runCmd); + const simctl: AppleSimctlToolProvider = { + run: provider.simctl + ? coerceRun(provider.simctl.run) + : async (args, options) => await runSimctlCommand(runCommand, args, options), }; const plist = provider.plist ?? { readJson: async (plistPath: string, signal?: AbortSignal) => - await readPlistJsonWithCommand(merged.runCommand, plistPath, signal), + await readPlistJsonWithCommand(runCommand, plistPath, signal), }; + const tools = { simctl, runCommand }; return { - ...merged, - simctl: provider.simctl ?? { - run: async (args, options) => await runSimctlCommand(merged.runCommand, args, options), + ...provider, + simctl, + runCommand, + ...(provider.macosHelper ? { macosHelper: { run: coerceRun(provider.macosHelper.run) } } : {}), + simulatorFiles: provider.simulatorFiles ?? { + copyToHost: async (request) => + await (await import('./local-simulator-files.ts')).copySimulatorFileToHost(simctl, request), + openContainer: async (request) => + await (await import('./local-simulator-files.ts')).openSimulatorContainer(simctl, request), + stageFile: async (path, signal) => { + signal?.throwIfAborted(); + return { path, [Symbol.asyncDispose]: async () => {} }; + }, }, - devicectl: provider.devicectl ?? { - run: async (args, options) => - await merged.runCommand('xcrun', ['devicectl', ...args], options), + simulatorApps: provider.simulatorApps ?? { + list: async (device, options) => + await ( + await import('./local-simulator-apps.ts') + ).readSimulatorAppMetadata(tools, device, options), + readInfoPlist: async (request) => + await (await import('./local-simulator-apps.ts')).readSimulatorAppInfoPlist(tools, request), + }, + devicectl: { + run: provider.devicectl + ? coerceRun(provider.devicectl.run) + : async (args, options) => await runCommand('xcrun', ['devicectl', ...args], options), }, plist, macosHost: provider.macosHost ?? createLocalAppleMacOsHostProvider( - merged.runCommand, + runCommand, async (plistPath) => await plist.readJson(plistPath), ), + whichCommand: + provider.whichCommand ?? (async (cmd) => await whichCmd(cmd, commandHostEnvironment().env)), }; } @@ -144,7 +171,7 @@ type XcrunToolArgs = readonly ['devicectl' | 'xcdevice' | 'xctrace', ...string[] export async function runXcrun( args: ScopedSimctlCommand | XcrunToolArgs, - options?: ExecOptions, + options?: AppleToolExecOptions, ): Promise { const provider = resolveAppleToolProvider(); if (args[0] === 'simctl') { @@ -164,16 +191,6 @@ export async function readApplePlistJson( return (await resolveAppleToolProvider().plist?.readJson(plistPath, signal)) ?? null; } -function normalizeAppleToolProvider(provider: AppleToolProvider): AppleToolProvider { - return createLocalAppleToolProvider({ - ...provider, - runCommand: coerceRunCommand(provider.runCommand), - simctl: { run: coerceRun(provider.simctl.run) }, - devicectl: { run: coerceRun(provider.devicectl.run) }, - ...(provider.macosHelper ? { macosHelper: { run: coerceRun(provider.macosHelper.run) } } : {}), - }); -} - // Scoped providers are SDK-supplied callbacks; coerce their results once at // the boundary (see coerceExecResult) so platform code can trust the types. function coerceRunCommand(run: AppleToolCommandExecutor): AppleToolCommandExecutor { diff --git a/packages/platform-apple/src/deployment/runtime.test.ts b/packages/platform-apple/src/deployment/runtime.test.ts index c52e912a47..d2cbf3c568 100644 --- a/packages/platform-apple/src/deployment/runtime.test.ts +++ b/packages/platform-apple/src/deployment/runtime.test.ts @@ -684,3 +684,68 @@ test.each([ expect(uninstalls()).toEqual([argv]); }, ); + +test.each([false, true])( + 'reinstall clears the old app before preparing and installing with a hosted installer: %s', + async (hosted) => { + const events: string[] = []; + const artifact = { + installablePath: '/tmp/new.app', + bundleId: 'com.example.app', + cleanup: async () => { + events.push('cleanup'); + }, + }; + const executor: AppleAppDeploymentExecutor = { + withInvalidatedAppResolutionCache: async (_device, operation) => { + events.push('invalidate-before'); + try { + return await operation(); + } finally { + events.push('invalidate-after'); + } + }, + resolveAppBundleId: async () => 'com.example.app', + prepareArtifact: async () => { + events.push('materialize'); + return artifact; + }, + }; + const host = deploymentHost( + executor, + xcrunLikeRun((request) => { + if (request.args[0] === 'uninstall' || request.args[0] === 'install') + events.push(request.args[0]); + return bootedSimulatorListResult(request); + }), + ); + const operations = createAppleAppDeploymentOperations({ + host, + device: appleDevice(), + signal: new AbortController().signal, + ...(hosted + ? { + install: async () => { + events.push('install'); + return { bundleId: 'com.example.app' }; + }, + } + : {}), + }); + expect( + await operations.deployApp!({ + app: 'Example', + appPath: '/tmp/new.app', + replaceExisting: true, + }), + ).toEqual({ bundleId: 'com.example.app', launchTarget: 'com.example.app' }); + expect(events).toEqual([ + 'invalidate-before', + 'uninstall', + 'materialize', + 'install', + 'cleanup', + 'invalidate-after', + ]); + }, +); diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index 5e2daa4d6c..59ee5d2b28 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -1,5 +1,7 @@ +import { resolveAppleToolProvider } from '../core/tool-provider.ts'; import type { AppDeploymentInput, + AppDeploymentInstaller, AppDeploymentResult, AppDeploymentRuntimeOperations, AppUninstallInput, @@ -64,18 +66,20 @@ export function createAppleAppDeploymentOperations(params: { host: PlatformRuntimeHost; device: DeviceInfo; signal: AbortSignal; + install?: AppDeploymentInstaller; }): Partial { const { host, device, signal } = params; + const install = params.install; const facts = appleAppDeploymentFacts(device); return Object.freeze({ ...(facts.deployApp.available ? { deployApp: async (input: AppDeploymentInput) => - await deployAppleApp(host, device, input, signal), + await deployAppleApp(host, device, input, signal, install), materializeAppSource: async (input: MaterializeAppSourceInput) => await host.appleDeployment.prepareArtifact(input, { signal }), deployMaterializedApp: async (input: DeployMaterializedAppInput) => - await deployPreparedAppleApp(host, device, input, signal), + await deployPreparedAppleApp(host, device, input, signal, install), uninstallApp: async (input: AppUninstallInput) => await uninstallResolvedAppleApp(host, device, input, signal), } @@ -96,6 +100,7 @@ async function deployAppleApp( device: DeviceInfo, input: AppDeploymentInput, signal: AbortSignal, + install?: AppDeploymentInstaller, ): Promise { // Preserve reinstall's established partial-failure boundary: the old route resolved and // removed the selected app before it materialized the replacement artifact. That ordering is @@ -109,7 +114,8 @@ async function deployAppleApp( { appIdentifierHint: input.app, signal }, ); try { - await installAppleApp(host, device, artifact.installablePath, signal); + if (install) await install(artifact, signal); + else await installAppleApp(host, device, artifact.installablePath, signal); return { bundleId, launchTarget: bundleId }; } finally { await artifact.cleanup(); @@ -122,7 +128,7 @@ async function deployAppleApp( { appIdentifierHint: input.app, signal }, ); try { - return await deployPreparedAppleApp(host, device, { artifact }, signal); + return await deployPreparedAppleApp(host, device, { artifact }, signal, install); } finally { await artifact.cleanup(); } @@ -156,7 +162,9 @@ async function deployPreparedAppleApp( device: DeviceInfo, input: DeployMaterializedAppInput, signal: AbortSignal, + install?: AppDeploymentInstaller, ): Promise { + if (install) return await install(input.artifact, signal); await installAppleApp(host, device, input.artifact.installablePath, signal); return { ...(input.artifact.bundleId ? { bundleId: input.artifact.bundleId } : {}), @@ -235,15 +243,20 @@ async function pushAppleNotification( }); try { await payload.writeText(`${JSON.stringify(input.payload)}\n`); - await runAppleTool( - host, - { - tool: 'simctl', - args: scopeSimctlArgsForDevice(device, ['push', device.id, input.appId, payload.path]), - }, - signal, - 'Apple push notification failed', - ); + const staged = await resolveAppleToolProvider().simulatorFiles.stageFile(payload.path, signal); + try { + await runAppleTool( + host, + { + tool: 'simctl', + args: scopeSimctlArgsForDevice(device, ['push', device.id, input.appId, staged.path]), + }, + signal, + 'Apple push notification failed', + ); + } finally { + await staged[Symbol.asyncDispose](); + } } finally { await payload[Symbol.asyncDispose](); } diff --git a/packages/platform-apple/src/foldable/fold-helper.ts b/packages/platform-apple/src/foldable/fold-helper.ts index d37b176a58..1891ed319f 100644 --- a/packages/platform-apple/src/foldable/fold-helper.ts +++ b/packages/platform-apple/src/foldable/fold-helper.ts @@ -8,7 +8,7 @@ import { } from '@agent-device/host-kit/command'; import { IOS_HINGE_ANGLE_READ_TIMEOUT_MS } from '../core/config.ts'; -import { runSimctlForDevice } from '../core/simctl.ts'; +import { runSimctlForDevice } from '../core/simctl-execution.ts'; import { ensureFoldHelperBinary, type FoldHelperBinary } from './fold-helper-cache.ts'; /** The read returned no usable sample; the details say why. */ diff --git a/packages/platform-apple/src/interactor.ts b/packages/platform-apple/src/interactor.ts index 530b148b22..a645f7927f 100644 --- a/packages/platform-apple/src/interactor.ts +++ b/packages/platform-apple/src/interactor.ts @@ -1,5 +1,6 @@ import { assertScreenshotCaptureSupport } from '@agent-device/contracts/screenshot-runtime'; import { appleScreenshotCaptureCapabilities } from './screenshot-support.ts'; +import { hasScopedAppleToolProvider } from './core/tool-provider.ts'; import { closeIosApp, openIosApp, openIosDevice } from './core/app-launch.ts'; import { readIosClipboardText, writeIosClipboardText } from './core/app-device-io.ts'; import { readIosSetting, setIosSetting } from './core/app-settings.ts'; @@ -94,10 +95,17 @@ export function createAppleInteractor( env, ), snapshot: async (options) => - await captureAppleSnapshot(device, options, runnerOpts, helperSurface(options?.surface), env), + await captureAppleSnapshot( + device, + { ...options, appBundleId: options?.appBundleId ?? runnerContext.appBundleId }, + runnerOpts, + helperSurface(options?.surface), + env, + ), // The live text at a point: helper for a helper-routed macOS surface, XCTest runner for // every other Apple leaf. - readTextAtPoint: async (point, options) => { + readTextAtPoint: async (point, input) => { + const options = { ...input, appBundleId: input?.appBundleId ?? runnerContext.appBundleId }; const helper = helperSurface(options?.surface); return helper ? await readMacOsSurfaceTextAtPoint( @@ -115,7 +123,11 @@ export function createAppleInteractor( findText: async (text, options) => { const result = (await runAppleRunnerCommand( device, - { command: 'findText', text, appBundleId: options?.appBundleId }, + { + command: 'findText', + text, + appBundleId: options?.appBundleId ?? runnerContext.appBundleId, + }, options?.signal ? { ...runnerOpts, signal: options.signal } : runnerOpts, )) as { found?: boolean }; return { found: result?.found === true }; @@ -235,10 +247,36 @@ export function createAppleInteractor( setSetting: (setting, state, appId, options) => setIosSetting(device, setting, state, appId, options, env, runnerOpts.signal), readSetting: (setting) => readIosSetting(device, setting), - readAlert: (options) => readAppleAlert(device, runnerOpts, options, env), - awaitAlert: (options) => awaitAppleAlert(device, runnerOpts, options, env), - acceptAlert: (options) => actOnAppleAlert(device, runnerOpts, 'accept', options, env), - dismissAlert: (options) => actOnAppleAlert(device, runnerOpts, 'dismiss', options, env), + readAlert: (options) => + readAppleAlert( + device, + runnerOpts, + { ...options, appBundleId: options?.appBundleId ?? runnerContext.appBundleId }, + env, + ), + awaitAlert: (options) => + awaitAppleAlert( + device, + runnerOpts, + { ...options, appBundleId: options?.appBundleId ?? runnerContext.appBundleId }, + env, + ), + acceptAlert: (options) => + actOnAppleAlert( + device, + runnerOpts, + 'accept', + { ...options, appBundleId: options?.appBundleId ?? runnerContext.appBundleId }, + env, + ), + dismissAlert: (options) => + actOnAppleAlert( + device, + runnerOpts, + 'dismiss', + { ...options, appBundleId: options?.appBundleId ?? runnerContext.appBundleId }, + env, + ), ...overrides, }; const served = @@ -448,8 +486,7 @@ function readRunnerOrientation(result: Record): DeviceRotation * composed OUTSIDE a daemon request (on daemon requests the request-boundary * `appleRunnerProvider` resolver scopes the same transport around everything): * runner-command methods run inside the provider scope, while methods backed - * by local Apple tooling (simctl/devicectl) have no provider-neutral transport - * and fail fast until the provider session composes its own on top. + * by Apple tooling require a scoped tool provider so remote execution cannot reach local tools. */ function withInjectedAppleRunnerTransport( device: DeviceInfo, @@ -460,13 +497,34 @@ function withInjectedAppleRunnerTransport( ): Interactor { const providerInteractor: Interactor = { ...interactor, - open: async () => rejectLocalAppleToolMethod('open'), - openDevice: async () => rejectLocalAppleToolMethod('openDevice'), - close: async () => rejectLocalAppleToolMethod('close'), - screenshot: async () => rejectLocalAppleToolMethod('screenshot'), - readClipboard: async () => rejectLocalAppleToolMethod('readClipboard'), - setSetting: async () => rejectLocalAppleToolMethod('setSetting'), - readSetting: async () => rejectLocalAppleToolMethod('readSetting'), + open: async (...args) => { + if (!hasScopedAppleToolProvider()) rejectLocalAppleToolMethod('open'); + return await interactor.open!(...args); + }, + openDevice: async (...args) => { + if (!hasScopedAppleToolProvider()) rejectLocalAppleToolMethod('openDevice'); + return await interactor.openDevice!(...args); + }, + close: async (...args) => { + if (!hasScopedAppleToolProvider()) rejectLocalAppleToolMethod('close'); + return await interactor.close!(...args); + }, + screenshot: async (...args) => { + if (!hasScopedAppleToolProvider()) rejectLocalAppleToolMethod('screenshot'); + return await interactor.screenshot!(...args); + }, + readClipboard: async (...args) => { + if (!hasScopedAppleToolProvider()) rejectLocalAppleToolMethod('readClipboard'); + return await interactor.readClipboard!(...args); + }, + setSetting: async (...args) => { + if (!hasScopedAppleToolProvider()) rejectLocalAppleToolMethod('setSetting'); + return await interactor.setSetting!(...args); + }, + readSetting: async (...args) => { + if (!hasScopedAppleToolProvider()) rejectLocalAppleToolMethod('readSetting'); + return await interactor.readSetting!(...args); + }, // The foldable probe and HID dispatch use local tooling; a provider device keeps the runner. setOrientation: async (orientation) => await setRunnerOrientation(device, orientation, runnerContext, runnerOpts), diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index 343e1c2316..f3b27c4bb9 100644 --- a/packages/platform-apple/src/launch-confirmation.test.ts +++ b/packages/platform-apple/src/launch-confirmation.test.ts @@ -9,7 +9,11 @@ import { AppError } from '@agent-device/kernel/errors'; import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command'; import { resolveIosSimulatorDeepLinkBundleId } from './core/app-resolution.ts'; import { IOS_APP_LAUNCH_TIMEOUT_MS } from './core/config.ts'; -import { type AppleToolProvider, withAppleToolProvider } from './core/tool-provider.ts'; +import { + type AppleToolProvider, + withAppleToolProvider, + createLocalAppleToolProvider, +} from './core/tool-provider.ts'; import { answerLaunchConfirmation, answerSimulatorLaunchConfirmation, @@ -242,11 +246,13 @@ function wedgedCoreSimulator( ); }); return { - whichCommand: async () => true, - runCommand: async (cmd, args, options) => - answered(`${cmd} ${args.at(-1)}`) ?? (await hang(options)), - simctl: { run: async (args, options) => answered(args[0] ?? '') ?? (await hang(options)) }, - devicectl: { run: async (_args, options) => await hang(options) }, + ...createLocalAppleToolProvider({ + whichCommand: async () => true, + runCommand: async (cmd, args, options) => + answered(`${cmd} ${args.at(-1)}`) ?? (await hang(options)), + simctl: { run: async (args, options) => answered(args[0] ?? '') ?? (await hang(options)) }, + devicectl: { run: async (_args, options) => await hang(options) }, + }), spawns: () => spawns, }; } @@ -736,3 +742,21 @@ test('abandoned accepts stop once the request cannot hold another press', async expect(acceptAlert).toHaveBeenCalledTimes(2); }); }); + +test('forwarded launch confirmation uses its provider URL resolver without reading local app containers', async () => { + const local = vi.mocked(resolveIosSimulatorDeepLinkBundleId); + local.mockClear(); + const signal = new AbortController().signal; + const resolveOwner = vi.fn(async () => undefined); + const port = createLaunchConfirmationPort( + simulator, + { appBundleId: 'com.example.app', url: 'example://home' }, + { awaitAlert: async () => ({}), readAlert: async () => ({}), acceptAlert: async () => ({}) }, + signal, + undefined, + resolveOwner, + ); + await expect(port.resolveUrlOwner()).resolves.toBeUndefined(); + expect(resolveOwner).toHaveBeenCalledWith(simulator, 'example://home', signal); + expect(local).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-apple/src/launch-confirmation.ts b/packages/platform-apple/src/launch-confirmation.ts index 44c870349a..dd0623cece 100644 --- a/packages/platform-apple/src/launch-confirmation.ts +++ b/packages/platform-apple/src/launch-confirmation.ts @@ -221,12 +221,24 @@ export async function answerSimulatorLaunchConfirmation( interactor: Promise, signal: AbortSignal, deadlineAtMs: number | undefined, + resolveUrlOwner?: ( + device: DeviceInfo, + url: string, + signal: AbortSignal, + ) => Promise, ): Promise { const settled: Readonly<{ interactor: Interactor }> | LaunchConfirmationAttempt = await interactor.then((resolved) => ({ interactor: resolved }), unreadable('runner')); if ('outcome' in settled) return settled; return await answerLaunchConfirmation( - createLaunchConfirmationPort(device, confirmation, settled.interactor, signal, deadlineAtMs), + createLaunchConfirmationPort( + device, + confirmation, + settled.interactor, + signal, + deadlineAtMs, + resolveUrlOwner, + ), ); } @@ -236,6 +248,11 @@ export function createLaunchConfirmationPort( interactor: Pick, signal: AbortSignal, deadlineAtMs: number | undefined, + resolveUrlOwner?: ( + device: DeviceInfo, + url: string, + signal: AbortSignal, + ) => Promise, ): LaunchConfirmationPort { const awaitAlert = interactor.awaitAlert?.bind(interactor); const readAlert = interactor.readAlert?.bind(interactor); @@ -253,10 +270,12 @@ export function createLaunchConfirmationPort( ), readAlert: async () => await alertIfPresent(readAlert(target)), acceptAlert: async () => await acceptAlert(target), - resolveUrlOwner: async () => - await resolveIosSimulatorDeepLinkBundleId(device, url, { - timeoutMs: URL_OWNER_LOOKUP_TIMEOUT_MS, - signal, - }), + resolveUrlOwner: resolveUrlOwner + ? () => resolveUrlOwner(device, url, signal) + : async () => + await resolveIosSimulatorDeepLinkBundleId(device, url, { + timeoutMs: URL_OWNER_LOOKUP_TIMEOUT_MS, + signal, + }), }); } diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index 45ab3cefe8..6518836757 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -17,7 +17,8 @@ import { invokeApplicationOpen, } from '@agent-device/contracts/application-lifecycle-interaction'; import { isDeepLinkTarget, isWebUrl } from '@agent-device/contracts/command'; -import { ensureAppleReady } from './readiness/runtime.ts'; +import { hasSimulatorBridge } from './snapshot-observability.ts'; +import { ensureAppleReady, type AppleReadinessOptions } from './readiness/runtime.ts'; import { resolveRunnerPrewarmPolicy, settleAppleOpen, @@ -58,9 +59,49 @@ type AppleLifecycleParams = Readonly<{ observation?: LaunchObservationPort; }>; -/** Apple owns its lifecycle ordering; the root host exposes only lazy runner/tool ports. */ +type AppleLifecycleServices = Pick< + PlatformRuntimeHost, + 'appleApplications' | 'clock' | 'localInteractors' +> & { + ensureReady( + device: DeviceInfo, + signal: AbortSignal, + options?: AppleReadinessOptions, + ): Promise; + shutdownTarget: PlatformRuntimeHost['deviceShutdown']['apple']['shutdownTarget']; + resolveDevClientLaunchUrl( + device: DeviceInfo, + input: OpenApplicationInput, + ): Promise; + resolveUrlOwner?: ( + device: DeviceInfo, + url: string, + signal: AbortSignal, + ) => Promise; + runnerFreeObservation: boolean; +}; + +/** Local host tools supply the same lifecycle sequencing used by forwarded Apple runners. */ export function bindAppleApplicationLifecycle( params: AppleLifecycleParams, +): ApplicationLifecycleRuntimeOperations { + return bindAppleApplicationLifecycleWithServices({ + ...params, + host: { + ...params.host, + ensureReady: (device, signal, options) => + ensureAppleReady(params.host, device, signal, options), + shutdownTarget: (...args) => params.host.deviceShutdown.apple.shutdownTarget(...args), + resolveDevClientLaunchUrl: async (device, input) => + await (await loadExpoDevClientLaunch()).resolveExpoDevClientLaunchUrl(device, input), + runnerFreeObservation: hasSimulatorBridge(params.device), + }, + }); +} + +/** Apple owns open, prewarm, relaunch and close ordering independently of host access. */ +export function bindAppleApplicationLifecycleWithServices( + params: Omit & { host: AppleLifecycleServices }, ): ApplicationLifecycleRuntimeOperations { const bindInteractor = (device: DeviceInfo) => bindLocalApplicationLifecycleInteractor({ @@ -78,7 +119,7 @@ export function bindAppleApplicationLifecycle( resolveOpenTarget: async (input) => await params.host.appleApplications.resolveOpenTarget(device, input), prepareApplicationOpen: async (input) => { - const readied = await ensureAppleReady(params.host, device, params.signal, { + const readied = await params.host.ensureReady(device, params.signal, { deadlineAtMs: input.execution.startupDeadlineAtMs, onColdBootStart: input.prewarmRunnerOnColdBoot ? () => { @@ -98,7 +139,7 @@ export function bindAppleApplicationLifecycle( await params.host.appleApplications.clearRuntimeHints(device, input), closeApplication: async (input) => { if (input.ensureReady) { - adoptReadiedDevice(await ensureAppleReady(params.host, device, params.signal)); + adoptReadiedDevice(await params.host.ensureReady(device, params.signal)); } await closeAppleApplication(params.host, binding, input); }, @@ -116,7 +157,7 @@ export function bindAppleApplicationLifecycle( type BoundAppleInteractor = ReturnType; async function openAppleApplication( - host: AppleLifecycleHost, + host: AppleLifecycleServices, binding: BoundAppleInteractor, input: OpenApplicationInput, observation: LaunchObservationPort | undefined, @@ -124,7 +165,7 @@ async function openAppleApplication( const timing: MutableOpenTiming = {}; const localIosSimulator = isIosSimulator(binding.device); const runner = createRunnerPrewarm(host, binding, input, timing); - const policy = resolveRunnerPrewarmPolicy(binding.device, input, localIosSimulator); + const policy = resolveRunnerPrewarmPolicy(binding.device, input, host.runnerFreeObservation); if (policy.runnerDemand) timing.runnerDemand = policy.runnerDemand; releaseSpeculativeRunner(host, binding, input, policy); const { shouldPrewarmRunner } = policy; @@ -144,6 +185,7 @@ async function openAppleApplication( timing, ); const devClientLaunchUrl = await applyAppleOpenRuntimeHints( + host, binding, input, localIosSimulator, @@ -173,7 +215,7 @@ async function openAppleApplication( localIosSimulator, { observation, - answerConfirmation: confirmationAnswer(binding, input, launch), + answerConfirmation: confirmationAnswer(host, binding, input, launch), redispatchLaunchUrl: confirmationRedispatch(binding, input, launch), }, timing, @@ -192,7 +234,7 @@ async function openAppleApplication( } async function closeAppleApplicationForRelaunch( - host: AppleLifecycleHost, + host: AppleLifecycleServices, binding: BoundAppleInteractor, input: OpenApplicationInput, localIosSimulator: boolean, @@ -214,6 +256,7 @@ async function closeAppleApplicationForRelaunch( } async function applyAppleOpenRuntimeHints( + host: AppleLifecycleServices, binding: BoundAppleInteractor, input: OpenApplicationInput, localIosSimulator: boolean, @@ -222,7 +265,7 @@ async function applyAppleOpenRuntimeHints( if (!hasRuntimeTransportHintValues(input.runtimeHints)) return undefined; const startedAtMs = Date.now(); const devClientLaunchUrl = localIosSimulator - ? await (await loadExpoDevClientLaunch()).resolveExpoDevClientLaunchUrl(binding.device, input) + ? await host.resolveDevClientLaunchUrl(binding.device, input) : undefined; if (!devClientLaunchUrl) { if (!input.applyRuntimeHints) { @@ -315,6 +358,7 @@ async function openLaunchUrl( * answered. */ function confirmationAnswer( + host: AppleLifecycleServices, binding: BoundAppleInteractor, input: OpenApplicationInput, { confirmation }: AppleLaunchPlan, @@ -328,6 +372,7 @@ function confirmationAnswer( binding.resolveInteractor(input.execution, confirmation.appBundleId), binding.signal, input.execution.requestDeadlineAtMs, + host.resolveUrlOwner, ); }; } @@ -360,7 +405,7 @@ async function finishAppleRunnerPrewarm( } async function notifyAppleRunnerRelaunch( - host: AppleLifecycleHost, + host: AppleLifecycleServices, binding: BoundAppleInteractor, input: OpenApplicationInput, localIosSimulator: boolean, @@ -414,7 +459,7 @@ function shouldRetainRunnerForRelaunch( } async function closeAppleApplication( - host: AppleLifecycleHost, + host: AppleLifecycleServices, binding: BoundAppleInteractor, input: CloseApplicationInput, ): Promise { @@ -430,7 +475,7 @@ async function closeAppleApplication( } async function finalizeAppleApplicationClose( - host: AppleLifecycleHost, + host: AppleLifecycleServices, device: DeviceInfo, signal: AbortSignal, input: CloseApplicationFinalizationInput, @@ -446,13 +491,13 @@ async function finalizeAppleApplicationClose( } const shutdown = input.shutdownTarget && isIosSimulator(device) - ? await host.deviceShutdown.apple.shutdownTarget(device, signal) + ? await host.shutdownTarget(device, signal) : undefined; return shutdown === undefined ? {} : { shutdown }; } async function prepareAppleRunner( - host: AppleLifecycleHost, + host: AppleLifecycleServices, device: DeviceInfo, signal: AbortSignal, input: PrepareAppleRunnerInput, @@ -464,7 +509,7 @@ async function prepareAppleRunner( return await withDiagnosticTimer( 'prepare_apple_runner', async () => { - const readied = await ensureAppleReady(host, device, signal, { deadlineAtMs }); + const readied = await host.ensureReady(device, signal, { deadlineAtMs }); const timeoutMs = Math.max(1, deadlineAtMs - Date.now()); emitDiagnostic({ phase: 'prepare_apple_runner', @@ -496,7 +541,7 @@ type RunnerPrewarm = Readonly<{ }>; function createRunnerPrewarm( - host: AppleLifecycleHost, + host: AppleLifecycleServices, binding: BoundAppleInteractor, input: OpenApplicationInput, timing: MutableOpenTiming, diff --git a/packages/platform-apple/src/runner-interaction-facts.ts b/packages/platform-apple/src/runner-interaction-facts.ts new file mode 100644 index 0000000000..dc85ed5321 --- /dev/null +++ b/packages/platform-apple/src/runner-interaction-facts.ts @@ -0,0 +1,142 @@ +import { + unavailableFact, + type RuntimeOperationFact, +} from '@agent-device/contracts/platform-runtime'; +import { isIosFamily, resolveDeviceAppleOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { snapshotRuntimeOperationFacts } from '@agent-device/contracts/snapshot-runtime'; +import { selectorObservationRuntimeOperationFacts } from '@agent-device/contracts/selector-observation-runtime'; +import { focusRuntimeOperationFacts } from '@agent-device/contracts/focus-runtime'; +import { typeTextRuntimeOperationFacts } from '@agent-device/contracts/type-text-runtime'; +import { touchRuntimeOperationFacts } from '@agent-device/contracts/touch-runtime'; +import { elementTextRuntimeOperationFacts } from '@agent-device/contracts/element-text-runtime'; +import { appleGestureAndScrollFacts } from './gesture-facts.ts'; +import { appleNavigationFacts } from './navigation/runtime.ts'; +import { appleSystemFacts } from './system/runtime.ts'; + +const available = Object.freeze({ available: true } as const); +const unavailable = unavailableFact('unsupported-platform-leaf'); +function unsupportedAppleDeviceKind(hint: string) { + return unavailableFact('unsupported-device-kind', hint); +} +/** + * Focus drives touch through the Apple interactor, which exists for the simulator and physical + * device kinds only. Parity with the retired `focus` capability bucket + * (`{ simulator: true, device: true }`), stated as one fact instead of an admission table. + */ +const focusKindUnavailable = unavailableFact( + 'unsupported-device-kind', + 'focus is supported on Apple simulators and physical devices.', +); + +const appStateUnavailable = unavailableFact( + 'unsupported-platform-leaf', + "Apple appstate is unavailable here: the runner reads the session app's XCUIApplication state on iOS-family simulators and physical devices only, and the Apple target answers no sessionless foreground probe. The per-command answer is the targetActivation disclosure, which a capture carries when its command had to re-activate the session app (#2682).", +); + +const elementTextLeafUnavailable = unavailableFact( + 'unsupported-platform-leaf', + 'watchOS has no XCUITest-driveable UI, so element text comes from the captured tree only.', +); + +const elementTextKindUnavailable = unavailableFact('unsupported-device-kind'); + +const snapshotKindUnavailable = unsupportedAppleDeviceKind( + 'snapshot is supported only for Apple simulators and devices.', +); + +const snapshotCustomActionsUnavailable = unavailableFact( + 'unsupported-platform-leaf', + 'Re-run without --actions, or target an iOS simulator.', +); + +const snapshotActiveAppRequired = unavailableFact( + 'owner-capability-missing', + 'Open the app under test before capturing its snapshot.', +); + +/** + * A live runner reads the session app's `XCUIApplication.state` on the kinds it drives; the read + * never starts one (see `bindAppleAppStateRuntime`). That is a fact about the session app, never a + * guess about the foreground (#2682): after `home` the app reports a background state, and which + * app took the screen stays nobody's to tell. + */ +function appleAppStateFact(device: DeviceInfo): RuntimeOperationFact { + if (!isIosFamily(device) || device.appleOs === 'watchos') return appStateUnavailable; + return device.kind === 'simulator' || device.kind === 'device' ? available : appStateUnavailable; +} + +function appleFocusFact(device: DeviceInfo): RuntimeOperationFact { + return device.kind === 'simulator' || device.kind === 'device' ? available : focusKindUnavailable; +} + +/** + * The live point read is the XCUITest runner's `readText` for app sessions and the macOS helper + * for desktop/menubar surfaces. Both need a driveable Apple UI, so watchOS and the non + * simulator/device kinds have no read at all. + */ +function appleElementTextFact(device: DeviceInfo) { + if (resolveDeviceAppleOs(device) === 'watchos') return elementTextLeafUnavailable; + return device.kind === 'simulator' || device.kind === 'device' + ? available + : elementTextKindUnavailable; +} + +function appleSnapshotFact(device: DeviceInfo) { + if (resolveDeviceAppleOs(device) === 'watchos') return snapshotKindUnavailable; + return device.kind === 'simulator' || device.kind === 'device' + ? available + : snapshotKindUnavailable; +} + +function appleSnapshotFacts(device: DeviceInfo) { + const capture = appleSnapshotFact(device); + return snapshotRuntimeOperationFacts({ + capture, + customActions: + capture.available && isIosFamily(device) && device.kind === 'simulator' + ? available + : snapshotCustomActionsUnavailable, + withoutActiveApp: isIosFamily(device) ? snapshotActiveAppRequired : capture, + }); +} +/** Apple interaction support is independent of who owns the simulator or transports runner commands. */ +export function appleRunnerInteractionFacts(device: DeviceInfo) { + return { + appState: appleAppStateFact(device), + prepareAppleRunner: applePrepareAppleRunnerFact(device), + ...appleSnapshotFacts(device), + ...selectorObservationRuntimeOperationFacts({ + findText: appleSnapshotFact(device), + querySelector: isIosFamily(device) ? appleFocusFact(device) : unavailable, + }), + ...focusRuntimeOperationFacts({ focus: appleFocusFact(device) }), + ...appleGestureAndScrollFacts(device), + ...typeTextRuntimeOperationFacts({ type: appleFocusFact(device) }), + ...touchRuntimeOperationFacts({ + unsupported: unavailable, + tap: appleFocusFact(device), + longPress: appleFocusFact(device), + fill: appleFocusFact(device), + ...(isIosFamily(device) ? { tapElementSelector: appleFocusFact(device) } : {}), + }), + ...elementTextRuntimeOperationFacts({ readTextAtPoint: appleElementTextFact(device) }), + ...appleNavigationFacts(device), + ...appleSystemFacts(device), + }; +} + +const watchPrepareUnavailable = unavailableFact( + 'unsupported-platform-leaf', + 'watchOS runner preparation is not supported because XCUITest cannot drive watchOS UI.', +); + +const applePrepareKindUnavailable = unsupportedAppleDeviceKind( + 'prepare is supported only for Apple simulators and devices.', +); + +export function applePrepareAppleRunnerFact(device: DeviceInfo) { + if (resolveDeviceAppleOs(device) === 'watchos') return watchPrepareUnavailable; + return device.kind === 'simulator' || device.kind === 'device' + ? available + : applePrepareKindUnavailable; +} diff --git a/packages/platform-apple/src/runner-operations-facade.ts b/packages/platform-apple/src/runner-operations-facade.ts index 467cec3cf2..2dfc83c912 100644 --- a/packages/platform-apple/src/runner-operations-facade.ts +++ b/packages/platform-apple/src/runner-operations-facade.ts @@ -41,3 +41,11 @@ export async function cleanupRunnerLeasesForOwner( const { runnerLeaseCleanupAdapter } = await import('@agent-device/apple-runner'); await cleanup(owner, runnerLeaseCleanupAdapter); } + +/** Binds provider-owned sessions to the shared Apple runner execution. */ +export async function createAppleRunnerRuntime( + options: Parameters[0], +) { + const { createAppleRunnerRuntime } = await import('./runner-runtime.ts'); + return createAppleRunnerRuntime(options); +} diff --git a/packages/platform-apple/src/runner-runtime.test.ts b/packages/platform-apple/src/runner-runtime.test.ts new file mode 100644 index 0000000000..3d4f67306b --- /dev/null +++ b/packages/platform-apple/src/runner-runtime.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from 'vitest'; +import { createAppleRunnerRuntime } from './runner-runtime.ts'; +import { IOS_SIMULATOR } from './__tests__/device-fixtures.ts'; + +test('runtime observations use the forwarded runner with request and application scope', async () => { + const commands: Record[] = []; + const requestIds: (string | undefined)[] = []; + let ready = false; + const runner = createAppleRunnerRuntime({ + device: IOS_SIMULATOR, + host: { env: {}, cwd: process.cwd() }, + provider: { + hasLiveSession: () => ready, + runCommand: async (_device, command, options) => { + ready = true; + commands.push(command); + requestIds.push(options.requestId); + return { + found: true, + nodes: [{ rect: { x: 1, y: 2, width: 30, height: 40 }, hittable: true }], + text: 'Continue', + appBundleId: 'com.example.app', + state: 'runningForeground', + }; + }, + }, + }); + const controller = new AbortController(); + const operations = runner.bindRunnerOperations({ + signal: controller.signal, + resolveInteractor: async (_device, context) => runner.createInteractor(context), + }); + expect(await operations.appState({ appBundleId: 'com.example.app' })).toEqual({}); + await runner.resetTarget(); + expect(commands).toHaveLength(0); + await runner.prepare( + { + execution: { requestId: 'prepare-remote' }, + timeoutMs: 1000, + }, + controller.signal, + ); + expect(commands.at(-1)?.command).toBe('uptime'); + expect(requestIds.at(-1)).toBe('prepare-remote'); + await runner.resetTarget(); + expect(commands.at(-1)?.command).toBe('targetReset'); + const options = { appBundleId: 'com.example.app' }; + const execution = { requestId: 'observe-remote' }; + expect( + await operations.querySelector({ + selector: { key: 'label', value: 'Continue' }, + options, + execution, + }), + ).toEqual({ + found: true, + node: { rect: { x: 1, y: 2, width: 30, height: 40 }, hittable: true }, + }); + expect(commands.at(-1)).toMatchObject({ + command: 'querySelector', + appBundleId: 'com.example.app', + selectorValue: 'Continue', + }); + expect(requestIds.at(-1)).toBe('observe-remote'); + expect(await operations.findText({ text: 'Continue', options, execution })).toEqual({ + found: true, + }); + expect(commands.at(-1)).toMatchObject({ command: 'findText', appBundleId: 'com.example.app' }); + expect(requestIds.at(-1)).toBe('observe-remote'); + expect(await operations.readTextAtPoint({ point: { x: 1, y: 2 }, options, execution })).toEqual({ + status: 'read', + text: 'Continue', + }); + expect(commands.at(-1)).toMatchObject({ command: 'readText', appBundleId: 'com.example.app' }); + expect(requestIds.at(-1)).toBe('observe-remote'); + const beforeCancel = commands.length; + controller.abort(); + await expect( + operations.querySelector({ selector: { key: 'label', value: 'Continue' }, options, execution }), + ).rejects.toThrow(); + expect(commands).toHaveLength(beforeCancel); +}); diff --git a/packages/platform-apple/src/runner-runtime.ts b/packages/platform-apple/src/runner-runtime.ts new file mode 100644 index 0000000000..c10d2fb909 --- /dev/null +++ b/packages/platform-apple/src/runner-runtime.ts @@ -0,0 +1,134 @@ +import { createAppleAppDeploymentOperations } from './deployment/runtime.ts'; +import type { + AppleApplicationTools, + PrepareAppleRunnerInput, + PrepareAppleRunnerResult, +} from '@agent-device/contracts/application-lifecycle-runtime'; +import { + type AppleRunnerProvider, + withAppleRunnerProvider, + prepareIosRunner, +} from '@agent-device/apple-runner'; +import { Deadline } from '@agent-device/host-kit/retry'; +import { AppError } from '@agent-device/kernel/errors'; +import { runAppleRunnerCommand } from './core/runner-client.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; +import { bindAppleApplicationLifecycleWithServices } from './lifecycle.ts'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import { createAppleInteractor } from './interactor.ts'; +import { appleRunnerInteractionFacts } from './runner-interaction-facts.ts'; +import { bindAppleAppStateRuntime } from './app-state-runtime.ts'; +import { bindAppleFindTextRuntime } from './runtime-snapshot.ts'; +import { bindAppleQuerySelectorRuntime } from './runtime-selector-query.ts'; +import { bindElementTextRuntime } from '@agent-device/contracts/element-text-runtime'; + +/** Binds a provider-owned runner session to the shared Apple command and runtime implementation. */ +export function createAppleRunnerRuntime(options: { + device: DeviceInfo; + host: HostEnvironment; + provider: AppleRunnerProvider; +}) { + const runCommand = options.provider; + const createInteractor = (context: RunnerContext = {}): Interactor => { + const interactor = createAppleInteractor(options.device, context, options.host, runCommand); + return { + ...interactor, + snapshot: async (input) => { + // Remote XCTest products target Runner.app; only the caller can name the app under test. + if (!(input?.appBundleId ?? context.appBundleId)) { + throw new AppError('INVALID_ARGS', 'Remote Apple snapshots require an app bundle ID', { + hint: 'Open an app in this session or pass snapshot({ appBundleId }).', + }); + } + return await interactor.snapshot(input); + }, + }; + }; + return { + facts: appleRunnerInteractionFacts(options.device), + bindDeployment: ( + request: Omit[0], 'device'>, + ) => createAppleAppDeploymentOperations({ ...request, device: options.device }), + createInteractor, + resetTarget: async (signal?: AbortSignal) => { + if (!options.provider.hasLiveSession(options.device)) return; + await withAppleRunnerProvider(runCommand, { deviceId: options.device.id }, () => + runAppleRunnerCommand(options.device, { command: 'targetReset' }, { signal }), + ); + }, + prepare: ( + input: PrepareAppleRunnerInput, + signal: AbortSignal, + ): Promise => + withAppleRunnerProvider( + runCommand, + { deviceId: options.device.id, requestId: input.execution.requestId }, + () => + prepareIosRunner(options.device, { + ...input.execution, + cleanStaleBundles: true, + buildTimeoutMs: input.timeoutMs, + startupTimeoutMs: input.timeoutMs, + prepareDeadline: Deadline.fromTimeoutMs(input.timeoutMs), + healthTimeoutMs: input.timeoutMs, + signal: AbortSignal.any([signal, AbortSignal.timeout(input.timeoutMs)]), + }), + ), + bindApplicationLifecycle: (request: { + signal: AbortSignal; + appleApplications: AppleApplicationTools; + clock: PlatformRuntimeHost['clock']; + ensureReady: Parameters< + typeof bindAppleApplicationLifecycleWithServices + >[0]['host']['ensureReady']; + resolveInteractor(device: DeviceInfo, context: RunnerContext): Promise; + }) => + bindAppleApplicationLifecycleWithServices({ + device: options.device, + signal: request.signal, + host: { + appleApplications: request.appleApplications, + clock: request.clock, + localInteractors: { resolve: request.resolveInteractor }, + ensureReady: request.ensureReady, + shutdownTarget: async () => { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'The provider owns the simulator lifecycle', + ); + }, + resolveDevClientLaunchUrl: async (device, input) => { + const { resolveExpoDevClientLaunchUrl } = await import('./expo-dev-client-launch.ts'); + return await resolveExpoDevClientLaunchUrl(device, input); + }, + runnerFreeObservation: false, + }, + }), + bindRunnerOperations: (request: { + signal: AbortSignal; + resolveInteractor(device: DeviceInfo, context: RunnerContext): Promise; + }) => { + const host = { + appleApplications: { + hasLiveRunnerSession: async () => options.provider.hasLiveSession(options.device), + }, + localInteractors: { resolve: request.resolveInteractor }, + }; + const params = { ...request, device: options.device }; + const query = bindAppleQuerySelectorRuntime(params); + return { + ...bindAppleAppStateRuntime(host, params), + ...bindAppleFindTextRuntime(host, { ...params, appBackend: 'xctest' }), + ...bindElementTextRuntime(params), + querySelector: (input: Parameters[0]) => + withAppleRunnerProvider( + runCommand, + { deviceId: options.device.id, requestId: input.execution?.requestId }, + () => query.querySelector(input), + ), + }; + }, + }; +} diff --git a/packages/platform-apple/src/runtime-snapshot.ts b/packages/platform-apple/src/runtime-snapshot.ts index 06548f2542..fec8941494 100644 --- a/packages/platform-apple/src/runtime-snapshot.ts +++ b/packages/platform-apple/src/runtime-snapshot.ts @@ -92,7 +92,10 @@ type SnapshotRuntimeOperation = Pick< * observation boundary defers those failures to canonical capture (ADR 0019 section 2). */ export function bindAppleFindTextRuntime( - host: PlatformRuntimeHost, + host: { + appleApplications: Pick; + localInteractors: Pick; + }, request: AppleSnapshotRequest, ): Pick { return Object.freeze({ @@ -117,7 +120,9 @@ type AdmittedAppleNativeFind = Readonly<{ appBundleId: string; signal: AbortSign * request/poll signal the runner call needs. */ async function admitAppleNativeFind( - host: Pick, + host: { + appleApplications: Pick; + }, request: AppleSnapshotRequest, input: Readonly<{ options?: Readonly<{ appBundleId?: string; surface?: SessionSurface }>; @@ -145,7 +150,9 @@ async function admitAppleNativeFind( * (see the find-runtime doc above). */ async function runnerCanAnswerNow( - host: Pick, + host: { + appleApplications: Pick; + }, device: DeviceInfo, execution: Readonly<{ requestId?: string }> | undefined, ): Promise { diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index c2caf245a9..19e2cd3c5b 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -1,3 +1,7 @@ +import { + appleRunnerInteractionFacts, + applePrepareAppleRunnerFact, +} from './runner-interaction-facts.ts'; import { type DeviceBinding, type RuntimeOperationFact, @@ -18,33 +22,17 @@ import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, } from '@agent-device/contracts/application-lifecycle-runtime'; -import { - bindElementTextRuntime, - elementTextRuntimeOperationFacts, -} from '@agent-device/contracts/element-text-runtime'; -import { - bindLocalFocusInteractor, - focusRuntimeOperationFacts, -} from '@agent-device/contracts/focus-runtime'; +import { bindElementTextRuntime } from '@agent-device/contracts/element-text-runtime'; +import { bindLocalFocusInteractor } from '@agent-device/contracts/focus-runtime'; import { bindLocalGestureInteractor } from '@agent-device/contracts/gesture-runtime'; import { bindLocalScrollInteractor } from '@agent-device/contracts/scroll-runtime'; import { bindLocalScreenshotInteractor, screenshotRuntimeOperationFacts, } from '@agent-device/contracts/screenshot-runtime'; -import { - selectorObservationRuntimeOperationFacts, - type SelectorObservationRuntimeOperations, -} from '@agent-device/contracts/selector-observation-runtime'; -import { snapshotRuntimeOperationFacts } from '@agent-device/contracts/snapshot-runtime'; -import { - bindLocalTypeTextInteractor, - typeTextRuntimeOperationFacts, -} from '@agent-device/contracts/type-text-runtime'; -import { - bindLocalTouchInteractor, - touchRuntimeOperationFacts, -} from '@agent-device/contracts/touch-runtime'; +import { type SelectorObservationRuntimeOperations } from '@agent-device/contracts/selector-observation-runtime'; +import { bindLocalTypeTextInteractor } from '@agent-device/contracts/type-text-runtime'; +import { bindLocalTouchInteractor } from '@agent-device/contracts/touch-runtime'; import { viewportRuntimeOperationFacts } from '@agent-device/contracts/viewport-runtime'; import { isIosFamily, @@ -57,7 +45,6 @@ import { perfRuntimeOperationFacts } from '@agent-device/contracts/perf-runtime' import { createHostAudioProbeCaptureOperations } from '@agent-device/capture-kit'; import { appleAudioProbeCaptureFact } from './audio/runtime.ts'; import { createApplePerfOperations } from './perf/runtime.ts'; -import { appleGestureAndScrollFacts } from './gesture-facts.ts'; import { createAppleAppLogRuntime } from './logs/runtime.ts'; import { dumpAppleNetworkTraffic } from './network/runtime.ts'; import { @@ -71,8 +58,8 @@ import { appleAppDeploymentFacts, createAppleAppDeploymentOperations, } from './deployment/runtime.ts'; -import { appleNavigationFacts, createAppleNavigationOperations } from './navigation/runtime.ts'; -import { appleSystemFacts, createAppleSystemOperations } from './system/runtime.ts'; +import { createAppleNavigationOperations } from './navigation/runtime.ts'; +import { createAppleSystemOperations } from './system/runtime.ts'; import { appleFoldableFacts, createAppleFoldableOperations } from './foldable/runtime.ts'; import { bindAppleFindTextRuntime, bindAppleSnapshotRuntime } from './runtime-snapshot.ts'; import { bindAppleQuerySelectorRuntime } from './runtime-selector-query.ts'; @@ -96,52 +83,23 @@ const perfUnavailable = unavailableFact( 'unsupported-device-backend', 'perf requires an Apple simulator, macOS device, or CoreDevice-backed physical Apple device.', ); -/** - * Focus drives touch through the Apple interactor, which exists for the simulator and physical - * device kinds only. Parity with the retired `focus` capability bucket - * (`{ simulator: true, device: true }`), stated as one fact instead of an admission table. - */ -const focusKindUnavailable = unavailableFact( - 'unsupported-device-kind', - 'focus is supported on Apple simulators and physical devices.', -); + /** Neither simctl nor CoreDevice injects a call or text message into an Apple target. */ const telephonyUnavailable = unavailableFact( 'unsupported-platform-leaf', 'telephony simulates calls and SMS through the Android emulator console; Apple simulators and devices expose no host control that injects a call or text message.', ); -const appStateUnavailable = unavailableFact( - 'unsupported-platform-leaf', - "Apple appstate is unavailable here: the runner reads the session app's XCUIApplication state on iOS-family simulators and physical devices only, and the Apple target answers no sessionless foreground probe. The per-command answer is the targetActivation disclosure, which a capture carries when its command had to re-activate the session app (#2682).", -); -/** - * A live runner reads the session app's `XCUIApplication.state` on the kinds it drives; the read - * never starts one (see `bindAppleAppStateRuntime`). That is a fact about the session app, never a - * guess about the foreground (#2682): after `home` the app reports a background state, and which - * app took the screen stays nobody's to tell. - */ -function appleAppStateFact(device: DeviceInfo): RuntimeOperationFact { - if (!isIosFamily(device) || device.appleOs === 'watchos') return appStateUnavailable; - return device.kind === 'simulator' || device.kind === 'device' ? available : appStateUnavailable; -} const headlessUnavailable = unavailableFact( 'unsupported-provider-mode', 'Headless boot is supported only for local Android emulators.', ); -const elementTextLeafUnavailable = unavailableFact( - 'unsupported-platform-leaf', - 'watchOS has no XCUITest-driveable UI, so element text comes from the captured tree only.', -); -const elementTextKindUnavailable = unavailableFact('unsupported-device-kind'); + const watchOpenTargetUnavailable = unavailableFact( 'unsupported-platform-leaf', 'watchOS open is not supported because XCUITest cannot drive watchOS UI.', ); -const watchPrepareUnavailable = unavailableFact( - 'unsupported-platform-leaf', - 'watchOS runner preparation is not supported because XCUITest cannot drive watchOS UI.', -); + const watchCloseTargetUnavailable = unavailableFact( 'unsupported-platform-leaf', 'watchOS close is not supported because XCUITest cannot drive watchOS UI.', @@ -154,9 +112,7 @@ const runtimeHintsUnavailable = unavailableFact( const appleOpenTargetKindUnavailable = unsupportedAppleDeviceKind( 'open is supported only for Apple simulators and devices.', ); -const applePrepareKindUnavailable = unsupportedAppleDeviceKind( - 'prepare is supported only for Apple simulators and devices.', -); + const appleCloseTargetKindUnavailable = unsupportedAppleDeviceKind( 'close is supported only for Apple simulators and devices.', ); @@ -168,13 +124,7 @@ const shutdownKindUnavailable = unavailableFact( 'unsupported-device-kind', 'shutdown is supported only for Apple simulators and Android emulators.', ); -const snapshotKindUnavailable = unsupportedAppleDeviceKind( - 'snapshot is supported only for Apple simulators and devices.', -); -const snapshotCustomActionsUnavailable = unavailableFact( - 'unsupported-platform-leaf', - 'Re-run without --actions, or target an iOS simulator.', -); + const screenshotWatchOsUnavailable = unavailableFact( 'unsupported-platform-leaf', 'screenshot is not supported on watchOS because XCUITest cannot drive watchOS UI.', @@ -182,10 +132,7 @@ const screenshotWatchOsUnavailable = unavailableFact( const screenshotKindUnavailable = unsupportedAppleDeviceKind( 'screenshot is supported only for Apple simulators and devices.', ); -const snapshotActiveAppRequired = unavailableFact( - 'owner-capability-missing', - 'Open the app under test before capturing its snapshot.', -); + function unsupportedAppleDeviceKind(hint: string) { return unavailableFact('unsupported-device-kind', hint); } @@ -220,13 +167,6 @@ function appleOpenTargetFact(device: DeviceInfo) { : appleOpenTargetKindUnavailable; } -function applePrepareAppleRunnerFact(device: DeviceInfo) { - if (resolveDeviceAppleOs(device) === 'watchos') return watchPrepareUnavailable; - return device.kind === 'simulator' || device.kind === 'device' - ? available - : applePrepareKindUnavailable; -} - function appleCloseTargetFact(device: DeviceInfo) { if (resolveDeviceAppleOs(device) === 'watchos') return watchCloseTargetUnavailable; return device.kind === 'simulator' || device.kind === 'device' @@ -255,10 +195,6 @@ function appInventoryFacts(device: DeviceInfo) { return available; } -function appleFocusFact(device: DeviceInfo): RuntimeOperationFact { - return device.kind === 'simulator' || device.kind === 'device' ? available : focusKindUnavailable; -} - export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { const appLogs = createAppleAppLogRuntime(host); const snapshotRoute = createAppleSnapshotRoute(host); @@ -288,37 +224,15 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR operations: { ...logs.operations, ...deployment, - appState: appleAppStateFact(device), networkDump: available, screenRecordingStart: recordingFacts, screenRecordingReattach: recordingFacts, screenRecordingCleanup: recordingFacts, - ...appleSnapshotFacts(device), + ...appleRunnerInteractionFacts(device), ...screenshotRuntimeOperationFacts({ capture: appleScreenshotFact(device) }), - ...selectorObservationRuntimeOperationFacts({ - findText: appleSnapshotFact(device), - // The live selector read rides the runner, so it shares the exact iOS-family kind cell - // the fused selector tap declares — not the macOS desktop or watchOS. - querySelector: isIosFamily(device) ? appleFocusFact(device) : unavailable, - }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), - ...focusRuntimeOperationFacts({ focus: appleFocusFact(device) }), - ...appleGestureAndScrollFacts(device), - // Text entry rides the same interactor authority the point focus does, so it shares the - // exact kind cell (parity with the retired `type` bucket, `{ simulator, device }`). - ...typeTextRuntimeOperationFacts({ type: appleFocusFact(device) }), - ...touchRuntimeOperationFacts({ - unsupported: unavailable, - tap: appleFocusFact(device), - longPress: appleFocusFact(device), - fill: appleFocusFact(device), - ...(isIosFamily(device) ? { tapElementSelector: appleFocusFact(device) } : {}), - }), - ...elementTextRuntimeOperationFacts({ readTextAtPoint: appleElementTextFact(device) }), - ...appleNavigationFacts(device), ...appleFoldableFacts(device), sendTelephonyEvent: telephonyUnavailable, - ...appleSystemFacts(device), ...audioProbeRuntimeOperationFacts({ capture: appleAudioProbeCaptureFact(device), query: audioQueryUnavailable, @@ -547,25 +461,6 @@ function applePerfFacts(device: DeviceInfo): Readonly<{ }; } -/** - * The live point read is the XCUITest runner's `readText` for app sessions and the macOS helper - * for desktop/menubar surfaces. Both need a driveable Apple UI, so watchOS and the non - * simulator/device kinds have no read at all. - */ -function appleElementTextFact(device: DeviceInfo) { - if (resolveDeviceAppleOs(device) === 'watchos') return elementTextLeafUnavailable; - return device.kind === 'simulator' || device.kind === 'device' - ? available - : elementTextKindUnavailable; -} - -function appleSnapshotFact(device: DeviceInfo) { - if (resolveDeviceAppleOs(device) === 'watchos') return snapshotKindUnavailable; - return device.kind === 'simulator' || device.kind === 'device' - ? available - : snapshotKindUnavailable; -} - /** * macOS surface selection (app window vs desktop/menubar) lives inside the Apple interactor's own * capture, so the ordinary local interactor binding covers every admitted Apple cell — unlike @@ -578,18 +473,6 @@ function appleScreenshotFact(device: DeviceInfo) { : screenshotKindUnavailable; } -function appleSnapshotFacts(device: DeviceInfo) { - const capture = appleSnapshotFact(device); - return snapshotRuntimeOperationFacts({ - capture, - customActions: - capture.available && isIosFamily(device) && device.kind === 'simulator' - ? available - : snapshotCustomActionsUnavailable, - withoutActiveApp: isIosFamily(device) ? snapshotActiveAppRequired : capture, - }); -} - /** * An operation is present on a binding only when the owner's own facts admitted it. One helper so * the binding below reads as a list of admitted operations rather than a chain of branches — and diff --git a/packages/platform-apple/src/snapshot-target.ts b/packages/platform-apple/src/snapshot-target.ts index d32aaab257..9afbe308cb 100644 --- a/packages/platform-apple/src/snapshot-target.ts +++ b/packages/platform-apple/src/snapshot-target.ts @@ -4,10 +4,10 @@ import type { ReadinessPhase } from '@agent-device/contracts/wait'; import { createDetachedAttempts, waitForDetachedAttempt } from './detached-attempt.ts'; import { readSimctlDevicesByRuntime, - runSimctlForDevice, simulatorAddressFor, type SimulatorAddress, } from './core/simctl.ts'; +import { runSimctlForDevice } from './core/simctl-execution.ts'; import { readSnapshotTargetProcessStartTime } from './snapshot-process.ts'; /** Identity re-check of a cached target: one local `ps`, never CoreSimulator IPC. */ diff --git a/packages/platform-apple/src/tool-provider-facade.ts b/packages/platform-apple/src/tool-provider-facade.ts index a7bb501e13..6db3885c6c 100644 --- a/packages/platform-apple/src/tool-provider-facade.ts +++ b/packages/platform-apple/src/tool-provider-facade.ts @@ -13,3 +13,8 @@ export type { AppleToolProvider, AppleToolSubcommandExecutor, } from './core/tool-provider.ts'; + +export async function readSimctlContainerPath(stdout: string): Promise { + const simctl = await import('./core/simctl.ts'); + return simctl.readSimctlContainerPath(stdout); +} diff --git a/src/platform-runtime-apple-tool-host.test.ts b/src/platform-runtime-apple-tool-host.test.ts index 39daddf034..55f44f648a 100644 --- a/src/platform-runtime-apple-tool-host.test.ts +++ b/src/platform-runtime-apple-tool-host.test.ts @@ -10,7 +10,7 @@ import { createAppleToolHost } from './platform-runtime-apple-tool-host.ts'; test('Apple tool host uses a full scoped provider when local xcrun is unavailable', async () => { const whichCommand = vi.fn(async () => true); const run = vi.fn(async () => ({ stdout: 'devices', stderr: '', exitCode: 0 })); - const provider: AppleToolProvider = { + const provider: AppleToolProvider = createLocalAppleToolProvider({ runCommand: async () => { throw new Error('local command fallback is unavailable'); }, @@ -21,7 +21,7 @@ test('Apple tool host uses a full scoped provider when local xcrun is unavailabl }, }, devicectl: { run }, - }; + }); const host = createAppleToolHost(); const signal = new AbortController().signal; diff --git a/test/integration/provider-scenarios/providers.ts b/test/integration/provider-scenarios/providers.ts index 34debdb785..adb194cfc5 100644 --- a/test/integration/provider-scenarios/providers.ts +++ b/test/integration/provider-scenarios/providers.ts @@ -1,3 +1,4 @@ +import { createLocalAppleToolProvider } from '@agent-device/platform-apple/tool-provider'; import type { AppleRunnerScreenRecordingTransport } from '../../../src/platform-runtime-screen-recording-apple-runner-transport.ts'; import type { AppleRunnerProvider, RunnerCommand } from '@agent-device/apple-runner'; import type { @@ -107,7 +108,7 @@ export function createRecordingAppleToolProvider(handlers: RecordingAppleToolHan }; return { calls, - provider: { + provider: createLocalAppleToolProvider({ whichCommand: async () => true, runCommand: async (cmd, args) => { calls.push([cmd, ...args]); @@ -149,7 +150,7 @@ export function createRecordingAppleToolProvider(handlers: RecordingAppleToolHan }, } : undefined, - }, + }), }; } From b663be74c1b2194330360793ab9187848be9a449 Mon Sep 17 00:00:00 2001 From: Muvaffak Onus Date: Wed, 4 Nov 2026 11:20:29 +0300 Subject: [PATCH 2/3] fix(apple): reject screen masks for provider runner captures --- .../src/core/__tests__/screenshot.test.ts | 20 +++++++++++++++++++ .../platform-apple/src/screenshot-support.ts | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/platform-apple/src/core/__tests__/screenshot.test.ts b/packages/platform-apple/src/core/__tests__/screenshot.test.ts index 35da996370..0ccc8457c2 100644 --- a/packages/platform-apple/src/core/__tests__/screenshot.test.ts +++ b/packages/platform-apple/src/core/__tests__/screenshot.test.ts @@ -64,6 +64,7 @@ import { runCmd } from '@agent-device/host-kit/command'; import { withDiagnosticsScope } from '@agent-device/host-kit/diagnostics'; import { retryWithPolicy } from '@agent-device/host-kit/retry'; import { AppError } from '@agent-device/kernel/errors'; +import { resolveAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts'; import { IOS_TEST_SIMULATOR, MACOS_TEST_DEVICE } from './apple-core-stub-helpers.ts'; @@ -887,3 +888,22 @@ test.each([MACOS_TEST_DEVICE, { ...IOS_TEST_SIMULATOR, kind: 'device' as const } assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0); }, ); + +test('screenshotIos rejects a screen mask before a provider-owned runner capture', async () => { + mockRunCmd.mockRejectedValue(new Error('unexpected native capture')); + mockRunAppleRunnerCommand.mockRejectedValue(new Error('unexpected runner capture')); + await withAppleToolProvider( + { ...resolveAppleToolProvider(), simulatorScreenshotBackend: 'runner' }, + async () => { + await assert.rejects( + screenshotIos(IOS_TEST_SIMULATOR, '/tmp/masked.png', { env: {}, screenMask: 'black' }), + { + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'screenshot-screen-mask-unsupported' }, + }, + ); + }, + ); + assert.equal(mockRunCmd.mock.calls.length, 0); + assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0); +}); diff --git a/packages/platform-apple/src/screenshot-support.ts b/packages/platform-apple/src/screenshot-support.ts index 858fcb8759..9a24a726f8 100644 --- a/packages/platform-apple/src/screenshot-support.ts +++ b/packages/platform-apple/src/screenshot-support.ts @@ -1,4 +1,5 @@ import { isHandheldAppleSimulator, type DeviceInfo } from '@agent-device/kernel/device'; +import { resolveAppleToolProvider } from './core/tool-provider.ts'; import type { ScreenshotCaptureCapabilities, ScreenshotOptions, @@ -9,6 +10,9 @@ export function appleScreenshotCaptureCapabilities( options: Pick, ): ScreenshotCaptureCapabilities { return { - screenMask: isHandheldAppleSimulator(device) && options.captureBackend !== 'runner', + screenMask: + isHandheldAppleSimulator(device) && + options.captureBackend !== 'runner' && + resolveAppleToolProvider().simulatorScreenshotBackend !== 'runner', }; } From a8758b33fb5f605d1b12c4e287ea69aa5bcab901 Mon Sep 17 00:00:00 2001 From: Muvaffak Onus Date: Wed, 4 Nov 2026 13:21:27 +0300 Subject: [PATCH 3/3] fix(apple): preserve scoped simulator behavior through shared tools --- .../app-resolution-deep-link.test.ts | 34 +++++++++++++++ .../platform-apple/src/core/app-resolution.ts | 16 +++---- .../src/core/local-simulator-files.ts | 12 +++++- .../src/core/simulator-files.test.ts | 37 ++++++++++++++++ .../src/deployment/runtime.test.ts | 42 +++++++++++++++++-- .../platform-apple/src/deployment/runtime.ts | 15 ++++--- .../src/launch-confirmation.test.ts | 18 -------- .../platform-apple/src/launch-confirmation.ts | 31 +++----------- packages/platform-apple/src/lifecycle.ts | 9 +--- 9 files changed, 142 insertions(+), 72 deletions(-) diff --git a/packages/platform-apple/src/core/__tests__/app-resolution-deep-link.test.ts b/packages/platform-apple/src/core/__tests__/app-resolution-deep-link.test.ts index bcfd56e3b8..b247bc8580 100644 --- a/packages/platform-apple/src/core/__tests__/app-resolution-deep-link.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-resolution-deep-link.test.ts @@ -142,3 +142,37 @@ test('rewriting the Info.plist invalidates the cached scheme set', async () => { assert.equal(plutilProbes(), probesBeforeRewrite + 1); }); }); + +test('deep-link ownership reads app metadata serially and still detects ambiguous owners', async () => { + const { createLocalAppleToolProvider, withAppleToolProvider } = + await import('../tool-provider.ts'); + let active = 0; + let peak = 0; + let reads = 0; + const apps = Array.from({ length: 20 }, (_, index) => ({ + bundleId: `com.example.app${index}`, + name: `App ${index}`, + applicationType: 'User', + })); + const provider = createLocalAppleToolProvider({ + simulatorApps: { + list: async () => ({ type: 'listed', apps }), + readInfoPlist: async () => { + active += 1; + peak = Math.max(peak, active); + reads += 1; + await new Promise((resolve) => setImmediate(resolve)); + active -= 1; + return { CFBundleURLTypes: [{ CFBundleURLSchemes: ['shared'] }] }; + }, + }, + }); + await withAppleToolProvider(provider, async () => { + assert.equal( + await resolveIosSimulatorDeepLinkBundleId(IOS_TEST_SIMULATOR, 'shared://home'), + undefined, + ); + }); + assert.equal(reads, 20); + assert.equal(peak, 1); +}); diff --git a/packages/platform-apple/src/core/app-resolution.ts b/packages/platform-apple/src/core/app-resolution.ts index e1e30e1db1..79d6f99110 100644 --- a/packages/platform-apple/src/core/app-resolution.ts +++ b/packages/platform-apple/src/core/app-resolution.ts @@ -213,16 +213,12 @@ export async function resolveIosSimulatorDeepLinkBundleId( const { remainingLookupBudget } = await import('./simulator-app-budget.ts'); const remaining = remainingLookupBudget(options); const apps = await listSimulatorAppMetadata(device, remaining()); - const candidates = apps.filter( - (app) => !app.bundleId.startsWith(AGENT_DEVICE_RUNNER_BUNDLE_PREFIX), - ); - const claims = await Promise.all( - candidates.map(async (app) => ({ - app, - schemes: await readInstalledAppUrlSchemes(device, app.bundleId, remaining(), app.path), - })), - ); - const matches = claims.filter(({ schemes }) => schemes.has(scheme)).map(({ app }) => app); + const matches: SimulatorAppMetadata[] = []; + for (const app of apps) { + if (app.bundleId.startsWith(AGENT_DEVICE_RUNNER_BUNDLE_PREFIX)) continue; + const schemes = await readInstalledAppUrlSchemes(device, app.bundleId, remaining(), app.path); + if (schemes.has(scheme)) matches.push(app); + } const userMatches = matches.filter((app) => app.applicationType === 'User'); if (userMatches.length === 1) return userMatches[0]?.bundleId; if (userMatches.length > 1) return undefined; diff --git a/packages/platform-apple/src/core/local-simulator-files.ts b/packages/platform-apple/src/core/local-simulator-files.ts index 27bcb4dfa5..194c2b2717 100644 --- a/packages/platform-apple/src/core/local-simulator-files.ts +++ b/packages/platform-apple/src/core/local-simulator-files.ts @@ -1,4 +1,8 @@ -import { readSimctlContainerPath, scopeSimctlArgsForDevice } from './simctl.ts'; +import { + readSimctlContainerPath, + scopeSimctlArgsForDevice, + simulatorAddressFor, +} from './simctl.ts'; import path from 'node:path'; import { copyHostFile, @@ -27,7 +31,11 @@ export async function copySimulatorFileToHost( await copyFromContainer('', request); return; } - const key = JSON.stringify([request.device.id, request.bundleId, request.containerType]); + const key = JSON.stringify([ + simulatorAddressFor(request.device), + request.bundleId, + request.containerType, + ]); const cached = containerPaths.get(key); if (cached) { try { diff --git a/packages/platform-apple/src/core/simulator-files.test.ts b/packages/platform-apple/src/core/simulator-files.test.ts index 38fc7b1b5f..a4de66922f 100644 --- a/packages/platform-apple/src/core/simulator-files.test.ts +++ b/packages/platform-apple/src/core/simulator-files.test.ts @@ -163,3 +163,40 @@ test('an absolute simulator screenshot path does not require an app container lo expect(await fs.readFile(destinationPath)).toEqual(bytes); expect(run).not.toHaveBeenCalled(); }); + +test('container copies distinguish the same simulator UDID in different simulator sets', async () => { + const directory = await mkdtempForTest('simulator-file-set-identity-'); + const sets = [path.join(directory, 'first'), path.join(directory, 'second')]; + for (const [index, setPath] of sets.entries()) { + await fs.mkdir(path.join(setPath, 'container'), { recursive: true }); + await fs.writeFile(path.join(setPath, 'container', 'file.bin'), `set ${index}`); + } + const run = vi.fn(async (args: readonly string[]) => ({ + stdout: path.join(args[1]!, 'container'), + stderr: '', + exitCode: 0, + })); + await withAppleToolProvider(createLocalAppleToolProvider({ simctl: { run } }), async () => { + for (const index of [0, 1, 0]) { + const destinationPath = path.join(directory, `copied-${index}.bin`); + await copySimulatorFileToHost({ + device: { + platform: 'apple', + appleOs: 'ios', + kind: 'simulator', + id: directory, + name: 'Same UDID', + simulatorSetPath: sets[index], + booted: true, + }, + bundleId: 'com.example.app', + containerType: 'data', + sourcePath: 'file.bin', + destinationPath, + timeoutMs: 1000, + }); + expect(await fs.readFile(destinationPath, 'utf8')).toBe(`set ${index}`); + } + }); + expect(run).toHaveBeenCalledTimes(2); +}); diff --git a/packages/platform-apple/src/deployment/runtime.test.ts b/packages/platform-apple/src/deployment/runtime.test.ts index d2cbf3c568..82502afb1f 100644 --- a/packages/platform-apple/src/deployment/runtime.test.ts +++ b/packages/platform-apple/src/deployment/runtime.test.ts @@ -714,8 +714,8 @@ test.each([false, true])( const host = deploymentHost( executor, xcrunLikeRun((request) => { - if (request.args[0] === 'uninstall' || request.args[0] === 'install') - events.push(request.args[0]); + if (request.args[0] === 'uninstall') events.push('uninstall'); + if (request.args[0] === 'install') events.push('native-install'); return bootedSimulatorListResult(request); }), ); @@ -726,7 +726,7 @@ test.each([false, true])( ...(hosted ? { install: async () => { - events.push('install'); + events.push('hosted-install'); return { bundleId: 'com.example.app' }; }, } @@ -743,9 +743,43 @@ test.each([false, true])( 'invalidate-before', 'uninstall', 'materialize', - 'install', + hosted ? 'hosted-install' : 'native-install', 'cleanup', 'invalidate-after', ]); }, ); + +test.each([ + [{}, { bundleId: 'com.example.app', appName: 'Example', launchTarget: 'com.example.app' }], + [ + { bundleId: 'com.installed.app' }, + { bundleId: 'com.installed.app', appName: 'Example', launchTarget: 'com.installed.app' }, + ], + [ + { bundleId: 'com.installed.app', appName: 'Installed', launchTarget: 'installed://home' }, + { bundleId: 'com.installed.app', appName: 'Installed', launchTarget: 'installed://home' }, + ], +])( + 'hosted install preserves metadata and fills artifact defaults: %j', + async (result, expected) => { + const run = xcrunLikeRun(bootedSimulatorListResult); + const operations = createAppleAppDeploymentOperations({ + host: deploymentHost({} as AppleAppDeploymentExecutor, run), + device: appleDevice(), + signal: new AbortController().signal, + install: async () => result, + }); + await expect( + operations.deployMaterializedApp!({ + artifact: { + installablePath: '/tmp/Example.app', + bundleId: 'com.example.app', + appName: 'Example', + cleanup: async () => {}, + }, + }), + ).resolves.toEqual(expected); + expect(run).not.toHaveBeenCalled(); + }, +); diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index 59ee5d2b28..376c1bb4dd 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -164,12 +164,17 @@ async function deployPreparedAppleApp( signal: AbortSignal, install?: AppDeploymentInstaller, ): Promise { - if (install) return await install(input.artifact, signal); - await installAppleApp(host, device, input.artifact.installablePath, signal); + let result: AppDeploymentResult = {}; + if (install) result = await install(input.artifact, signal); + else await installAppleApp(host, device, input.artifact.installablePath, signal); + const bundleId = result.bundleId ?? input.artifact.bundleId; + const appName = result.appName ?? input.artifact.appName; + const launchTarget = result.launchTarget ?? bundleId; return { - ...(input.artifact.bundleId ? { bundleId: input.artifact.bundleId } : {}), - ...(input.artifact.appName ? { appName: input.artifact.appName } : {}), - ...(input.artifact.bundleId ? { launchTarget: input.artifact.bundleId } : {}), + ...result, + ...(bundleId ? { bundleId } : {}), + ...(appName ? { appName } : {}), + ...(launchTarget ? { launchTarget } : {}), }; } diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index f3b27c4bb9..1c8df0f03e 100644 --- a/packages/platform-apple/src/launch-confirmation.test.ts +++ b/packages/platform-apple/src/launch-confirmation.test.ts @@ -742,21 +742,3 @@ test('abandoned accepts stop once the request cannot hold another press', async expect(acceptAlert).toHaveBeenCalledTimes(2); }); }); - -test('forwarded launch confirmation uses its provider URL resolver without reading local app containers', async () => { - const local = vi.mocked(resolveIosSimulatorDeepLinkBundleId); - local.mockClear(); - const signal = new AbortController().signal; - const resolveOwner = vi.fn(async () => undefined); - const port = createLaunchConfirmationPort( - simulator, - { appBundleId: 'com.example.app', url: 'example://home' }, - { awaitAlert: async () => ({}), readAlert: async () => ({}), acceptAlert: async () => ({}) }, - signal, - undefined, - resolveOwner, - ); - await expect(port.resolveUrlOwner()).resolves.toBeUndefined(); - expect(resolveOwner).toHaveBeenCalledWith(simulator, 'example://home', signal); - expect(local).not.toHaveBeenCalled(); -}); diff --git a/packages/platform-apple/src/launch-confirmation.ts b/packages/platform-apple/src/launch-confirmation.ts index dd0623cece..44c870349a 100644 --- a/packages/platform-apple/src/launch-confirmation.ts +++ b/packages/platform-apple/src/launch-confirmation.ts @@ -221,24 +221,12 @@ export async function answerSimulatorLaunchConfirmation( interactor: Promise, signal: AbortSignal, deadlineAtMs: number | undefined, - resolveUrlOwner?: ( - device: DeviceInfo, - url: string, - signal: AbortSignal, - ) => Promise, ): Promise { const settled: Readonly<{ interactor: Interactor }> | LaunchConfirmationAttempt = await interactor.then((resolved) => ({ interactor: resolved }), unreadable('runner')); if ('outcome' in settled) return settled; return await answerLaunchConfirmation( - createLaunchConfirmationPort( - device, - confirmation, - settled.interactor, - signal, - deadlineAtMs, - resolveUrlOwner, - ), + createLaunchConfirmationPort(device, confirmation, settled.interactor, signal, deadlineAtMs), ); } @@ -248,11 +236,6 @@ export function createLaunchConfirmationPort( interactor: Pick, signal: AbortSignal, deadlineAtMs: number | undefined, - resolveUrlOwner?: ( - device: DeviceInfo, - url: string, - signal: AbortSignal, - ) => Promise, ): LaunchConfirmationPort { const awaitAlert = interactor.awaitAlert?.bind(interactor); const readAlert = interactor.readAlert?.bind(interactor); @@ -270,12 +253,10 @@ export function createLaunchConfirmationPort( ), readAlert: async () => await alertIfPresent(readAlert(target)), acceptAlert: async () => await acceptAlert(target), - resolveUrlOwner: resolveUrlOwner - ? () => resolveUrlOwner(device, url, signal) - : async () => - await resolveIosSimulatorDeepLinkBundleId(device, url, { - timeoutMs: URL_OWNER_LOOKUP_TIMEOUT_MS, - signal, - }), + resolveUrlOwner: async () => + await resolveIosSimulatorDeepLinkBundleId(device, url, { + timeoutMs: URL_OWNER_LOOKUP_TIMEOUT_MS, + signal, + }), }); } diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index 6518836757..abe7593755 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -73,11 +73,6 @@ type AppleLifecycleServices = Pick< device: DeviceInfo, input: OpenApplicationInput, ): Promise; - resolveUrlOwner?: ( - device: DeviceInfo, - url: string, - signal: AbortSignal, - ) => Promise; runnerFreeObservation: boolean; }; @@ -215,7 +210,7 @@ async function openAppleApplication( localIosSimulator, { observation, - answerConfirmation: confirmationAnswer(host, binding, input, launch), + answerConfirmation: confirmationAnswer(binding, input, launch), redispatchLaunchUrl: confirmationRedispatch(binding, input, launch), }, timing, @@ -358,7 +353,6 @@ async function openLaunchUrl( * answered. */ function confirmationAnswer( - host: AppleLifecycleServices, binding: BoundAppleInteractor, input: OpenApplicationInput, { confirmation }: AppleLaunchPlan, @@ -372,7 +366,6 @@ function confirmationAnswer( binding.resolveInteractor(input.execution, confirmation.appBundleId), binding.signal, input.execution.requestDeadlineAtMs, - host.resolveUrlOwner, ); }; }