diff --git a/packages/host-kit/src/internal/exec.ts b/packages/host-kit/src/internal/exec.ts index 7f1f9f8356..d5f759c9d0 100644 --- a/packages/host-kit/src/internal/exec.ts +++ b/packages/host-kit/src/internal/exec.ts @@ -688,11 +688,13 @@ function createExitError( * than relying on the spawn layer to throw. `extra` accepts a function so * failure-only work (hint classification) is not paid on the success path. */ -export function requireExecSuccess( - result: ExecResult, +export function requireExecSuccess< + R extends Pick & Readonly<{ exitCode: number | null }>, +>( + result: R, message: string, - extra?: Record | ((result: ExecResult) => Record), -): ExecResult { + extra?: Record | ((result: R) => Record), +): R { if (result.exitCode === 0) return result; throw new AppError( 'COMMAND_FAILED', diff --git a/packages/platform-apple/src/__tests__/app-error.ts b/packages/platform-apple/src/__tests__/app-error.ts index 1a35896125..c7eb8aa3d6 100644 --- a/packages/platform-apple/src/__tests__/app-error.ts +++ b/packages/platform-apple/src/__tests__/app-error.ts @@ -1,7 +1,14 @@ import assert from 'node:assert/strict'; -import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { AppError, defaultHintForCode, normalizeError } from '@agent-device/kernel/errors'; -type ExpectedAppError = { code: string; message?: RegExp; hint?: string | RegExp }; +type ExpectedAppError = { + code: string; + message?: RegExp; + /** Checked against `normalizeError(error).message`, e.g. the stderr excerpt a command failure appends. */ + normalizedMessage?: RegExp; + /** `null` asserts the failure attached no hint of its own, so normalization falls back to the code default. */ + hint?: string | RegExp | null; +}; function assertAppError(error: unknown, expected: ExpectedAppError): true { assert.ok( @@ -10,7 +17,12 @@ function assertAppError(error: unknown, expected: ExpectedAppError): true { ); assert.equal(error.code, expected.code); if (expected.message) assert.match(error.message, expected.message); - if (expected.hint !== undefined) { + if (expected.normalizedMessage) { + assert.match(normalizeError(error).message, expected.normalizedMessage); + } + if (expected.hint === null) { + assert.equal(normalizeError(error).hint, defaultHintForCode(error.code)); + } else if (expected.hint !== undefined) { const { hint } = normalizeError(error); assert.ok(typeof hint === 'string', `expected a hint on ${error.code}, got ${String(hint)}`); if (typeof expected.hint === 'string') assert.equal(hint, expected.hint); diff --git a/packages/platform-apple/src/core/__tests__/app-device-io.test.ts b/packages/platform-apple/src/core/__tests__/app-device-io.test.ts deleted file mode 100644 index 374026368c..0000000000 --- a/packages/platform-apple/src/core/__tests__/app-device-io.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { beforeEach, expect, test, vi } from 'vitest'; -import type { DeviceInfo } from '@agent-device/kernel/device'; - -const { ensureBootedSimulator, runSimctl } = vi.hoisted(() => ({ - ensureBootedSimulator: vi.fn(), - runSimctl: vi.fn(), -})); - -vi.mock('../simulator.ts', () => ({ - ensureBootedSimulator, - requireSimulatorDevice: (device: DeviceInfo) => { - if (device.kind !== 'simulator') throw new Error('simulator required'); - }, -})); -vi.mock('../simctl.ts', async (importOriginal) => ({ - ...(await importOriginal()), - runSimctlForDevice: runSimctl, -})); - -import { pushIosNotification } from '../app-device-io.ts'; - -const device: DeviceInfo = { - platform: 'apple', - appleOs: 'ios', - id: 'apple-push-abort', - name: 'Apple push abort', - kind: 'simulator', - target: 'mobile', - booted: true, -}; - -beforeEach(() => { - ensureBootedSimulator.mockReset(); - ensureBootedSimulator.mockResolvedValue(undefined); - runSimctl.mockReset(); -}); - -test('aborts an in-flight simctl push with the deployment binding signal', async () => { - const controller = new AbortController(); - const aborted = new Error('request aborted'); - runSimctl.mockImplementation( - async (_device: DeviceInfo, _args: string[], options: Readonly<{ signal?: AbortSignal }>) => - await rejectWhenAborted(options.signal), - ); - - const pending = pushIosNotification( - device, - 'com.example.app', - { aps: {} }, - { - signal: controller.signal, - }, - ); - await vi.waitFor(() => expect(runSimctl).toHaveBeenCalledOnce()); - expect(runSimctl).toHaveBeenCalledWith( - device, - ['push', device.id, 'com.example.app', expect.stringContaining('payload.apns')], - { signal: controller.signal }, - ); - controller.abort(aborted); - - await expect(pending).rejects.toBe(aborted); -}); - -async function rejectWhenAborted(signal: AbortSignal | undefined): Promise { - return await new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); - }); -} diff --git a/packages/platform-apple/src/core/__tests__/apps.test.ts b/packages/platform-apple/src/core/__tests__/apps.test.ts index 1602dd0c50..4104358338 100644 --- a/packages/platform-apple/src/core/__tests__/apps.test.ts +++ b/packages/platform-apple/src/core/__tests__/apps.test.ts @@ -1,6 +1,6 @@ import { beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; -import { promises as fs, readFileSync, writeFileSync } from 'node:fs'; +import { promises as fs, writeFileSync } from 'node:fs'; import path from 'node:path'; import { mkdtempForTest } from '../../__tests__/tmp-dir.ts'; @@ -26,7 +26,7 @@ const retryActual = await vi.importActual('../simulator.ts'); import { closeIosApp, openIosApp } from '../app-launch.ts'; -import { pushIosNotification, readIosClipboardText } from '../app-device-io.ts'; +import { readIosClipboardText } from '../app-device-io.ts'; import { resolveIosApp, resolveIosSimulatorDeepLinkBundleId } from '../app-resolution.ts'; import { screenshotIos } from '../screenshot.ts'; import { withMockedMacOsHelper } from './macos-helper-test-utils.ts'; @@ -571,37 +571,6 @@ test('openIosApp with app and URL on iOS device launches app bundle with payload ); }); -test('pushIosNotification uses simctl push with temporary payload file', async () => { - const device: DeviceInfo = { - platform: 'apple', - id: 'sim-1', - name: 'iPhone', - kind: 'simulator', - booted: true, - }; - - let capturedPayload: string | undefined; - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args[0] === 'simctl' && args[1] === 'push') { - capturedPayload = readFileSync(args[4] ?? '', 'utf8'); - return ''; - } - return ''; - }, - async ({ calls }) => { - await pushIosNotification(device, 'com.example.app', { aps: { alert: 'hello', badge: 4 } }); - const pushCall = calls.find((args) => args[0] === 'simctl' && args[1] === 'push'); - assert.ok(pushCall); - assert.equal(pushCall[2], 'sim-1'); - assert.equal(pushCall[3], 'com.example.app'); - assert.match(pushCall[4] ?? '', /payload\.apns$/); - assert.deepEqual(JSON.parse(capturedPayload ?? ''), { aps: { alert: 'hello', badge: 4 } }); - }, - ); -}); - test('resolveIosApp resolves app display name on iOS physical devices', async () => { const device: DeviceInfo = { platform: 'apple', diff --git a/packages/platform-apple/src/core/app-device-io.ts b/packages/platform-apple/src/core/app-device-io.ts index 42ea707965..56bf0bac73 100644 --- a/packages/platform-apple/src/core/app-device-io.ts +++ b/packages/platform-apple/src/core/app-device-io.ts @@ -1,11 +1,5 @@ -import path from 'node:path'; import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; import { requireExecSuccess } from '@agent-device/host-kit/command'; -import { - makeHostTemporaryDirectory, - removeHostPath, - writeHostTextFile, -} from '@agent-device/host-kit/host-file'; import { ensureBootedSimulator, requireSimulatorDevice } from './simulator.ts'; import { readMacOsClipboardText, writeMacOsClipboardText } from '../os/macos/apps.ts'; import { runSimctlForDevice } from './simctl.ts'; @@ -38,24 +32,3 @@ export async function writeIosClipboardText(device: DeviceInfo, text: string): P 'Failed to write iOS simulator clipboard', ); } - -export async function pushIosNotification( - device: DeviceInfo, - bundleId: string, - payload: Record, - options: Readonly<{ signal?: AbortSignal }> = {}, -): Promise { - requireSimulatorDevice(device, 'push'); - options.signal?.throwIfAborted(); - await ensureBootedSimulator(device, { signal: options.signal }); - const tempDir = await makeHostTemporaryDirectory('agent-device-ios-push-'); - const payloadPath = path.join(tempDir, 'payload.apns'); - try { - await writeHostTextFile(payloadPath, `${JSON.stringify(payload)}\n`); - await runSimctlForDevice(device, ['push', device.id, bundleId, payloadPath], { - signal: options.signal, - }); - } finally { - await removeHostPath(tempDir); - } -} diff --git a/packages/platform-apple/src/core/physical-device-apps.ts b/packages/platform-apple/src/core/physical-device-apps.ts index 256782d280..fd2df11594 100644 --- a/packages/platform-apple/src/core/physical-device-apps.ts +++ b/packages/platform-apple/src/core/physical-device-apps.ts @@ -1,10 +1,8 @@ import type { AppsFilter } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { IOS_DEVICE_INSTALL_TIMEOUT_MS } from './config.ts'; import { listIosDeviceApps, resolveIosDeviceAppProcesses, - runIosDevicectl, terminateIosDeviceApp, } from './devicectl.ts'; import type { IosAppInfo, IosDeviceAppProcesses } from './app-info.ts'; @@ -16,40 +14,6 @@ export async function listCoreDeviceApps( return await listIosDeviceApps(device, filter); } -export async function installCoreDeviceApp( - device: DeviceInfo, - installablePath: string, - signal?: AbortSignal, -): Promise { - await runIosDevicectl( - ['device', 'install', 'app', '--device', device.id, installablePath], - { - action: 'install iOS app', - deviceId: device.id, - }, - { - signal, - timeoutMs: IOS_DEVICE_INSTALL_TIMEOUT_MS, - }, - ); -} - -export async function uninstallCoreDeviceApp( - device: DeviceInfo, - bundleId: string, - signal?: AbortSignal, -): Promise { - await runIosDevicectl( - ['device', 'uninstall', 'app', '--device', device.id, bundleId], - { action: `uninstall iOS app ${bundleId}`, deviceId: device.id }, - { - signal, - tolerateOutput: (stdout, stderr) => - isMissingAppErrorOutput(`${stdout}\n${stderr}`.toLowerCase()), - }, - ); -} - export async function terminateCoreDeviceApp(device: DeviceInfo, bundleId: string): Promise { await terminateIosDeviceApp(device, bundleId); } @@ -61,7 +25,7 @@ export async function resolveCoreDeviceAppProcesses( return await resolveIosDeviceAppProcesses(device, bundleId); } -function isMissingAppErrorOutput(output: string): boolean { +export function isMissingAppErrorOutput(output: string): boolean { return ( output.includes('not installed') || output.includes('not found') || diff --git a/packages/platform-apple/src/core/physical-device-control.ts b/packages/platform-apple/src/core/physical-device-control.ts index a0feeecc14..9d174219c1 100644 --- a/packages/platform-apple/src/core/physical-device-control.ts +++ b/packages/platform-apple/src/core/physical-device-control.ts @@ -4,11 +4,9 @@ import { execFailureDetails } from '@agent-device/host-kit/command'; import type { AppsFilter } from '@agent-device/contracts/device'; import type { IosAppInfo, IosDeviceAppProcesses } from './app-info.ts'; import { - installCoreDeviceApp, listCoreDeviceApps, resolveCoreDeviceAppProcesses, terminateCoreDeviceApp, - uninstallCoreDeviceApp, } from './physical-device-apps.ts'; import { ensureCoreDeviceReady, @@ -42,11 +40,8 @@ type IosPhysicalDeviceLaunchOptions = { }; export type IosPhysicalDeviceControl = IosPhysicalDeviceRunnerControl & { - assertAppInstallationSupported(device: DeviceInfo): void; ensureReady(device: DeviceInfo, signal?: AbortSignal): Promise; listApps(device: DeviceInfo, filter: AppsFilter): Promise; - installApp(device: DeviceInfo, installablePath: string, signal?: AbortSignal): Promise; - uninstallApp(device: DeviceInfo, bundleId: string, signal?: AbortSignal): Promise; launchApp( device: DeviceInfo, bundleId: string, @@ -77,11 +72,8 @@ export type IosPhysicalDeviceControl = IosPhysicalDeviceRunnerControl & { const CONTROLS: Record = { coredevice: { backend: 'coredevice', - assertAppInstallationSupported: () => {}, ensureReady: ensureCoreDeviceReady, listApps: listCoreDeviceApps, - installApp: installCoreDeviceApp, - uninstallApp: uninstallCoreDeviceApp, launchApp: launchCoreDeviceApp, terminateApp: async (device, bundleId) => await terminateCoreDeviceApp(device, bundleId), resolveAppProcesses: resolveCoreDeviceAppProcesses, @@ -94,11 +86,8 @@ const CONTROLS: Record = { }, xctest: { backend: 'xctest', - assertAppInstallationSupported: assertXctestAppInstallationUnsupported, ensureReady: ensureXctestDeviceReady, listApps: rejectXctestAppInventory, - installApp: rejectXctestAppInstallation, - uninstallApp: rejectXctestAppInstallation, launchApp: launchXctestDeviceApp, terminateApp: terminateXctestDeviceApp, resolveAppProcesses: rejectXctestProcessLookup, @@ -113,22 +102,6 @@ export function resolveIosPhysicalDeviceControl(device: DeviceInfo): IosPhysical return CONTROLS[device.iosPhysicalDeviceBackend === 'xctest' ? 'xctest' : 'coredevice']; } -function assertXctestAppInstallationUnsupported(device: DeviceInfo): never { - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'Installing apps is unavailable on this XCTest-backed physical iOS device.', - { - deviceId: device.id, - backend: 'xctest', - hint: 'Install the app with Xcode, then open it in agent-device by bundle ID.', - }, - ); -} - -async function rejectXctestAppInstallation(device: DeviceInfo): Promise { - return assertXctestAppInstallationUnsupported(device); -} - async function rejectXctestAppInventory(device: DeviceInfo): Promise { throw new AppError( 'UNSUPPORTED_OPERATION', diff --git a/packages/platform-apple/src/deployment/runtime.test.ts b/packages/platform-apple/src/deployment/runtime.test.ts index f477cd7954..e56a876049 100644 --- a/packages/platform-apple/src/deployment/runtime.test.ts +++ b/packages/platform-apple/src/deployment/runtime.test.ts @@ -1,9 +1,41 @@ import { expect, test, vi } from 'vitest'; import type { AppleAppDeploymentExecutor } from '@agent-device/contracts/app-deployment-runtime'; +import type { + AppleToolRequest, + HostCommandResult, +} from '@agent-device/contracts/platform-runtime-host'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { execFailureDetails } from '@agent-device/host-kit/command'; +import { assertRejectsAppError } from '../__tests__/app-error.ts'; import { appleAppDeploymentFacts, createAppleAppDeploymentOperations } from './runtime.ts'; +/** + * Mirrors runXcrun's own contract (host-kit exec.ts): a non-zero exit rejects with the same + * `execFailureDetails` shape `createExitError` builds — `processExitError: true` plus `cmd` and + * `args` — and no hint, unless the request set `allowFailure`. A fake that throws a bare + * COMMAND_FAILED cannot catch a call site that forgot `allowFailure`, or one whose caller drops + * the stderr excerpt `normalizeError` would otherwise surface (#2785). + */ +function xcrunLikeRun( + respond: ( + request: AppleToolRequest, + ) => Readonly<{ stdout: string; stderr: string; exitCode: number }>, +) { + return vi.fn(async (request: AppleToolRequest): Promise => { + const result = respond(request); + if (result.exitCode !== 0 && !request.allowFailure) { + throw new AppError( + 'COMMAND_FAILED', + `xcrun exited with code ${result.exitCode}`, + execFailureDetails(result, { cmd: 'xcrun', args: [request.tool, ...request.args] }), + ); + } + return result; + }); +} + function appleDevice(overrides: Partial = {}): DeviceInfo { return { platform: 'apple', @@ -24,9 +56,21 @@ async function withoutInvalidatingAppResolutionCache( return await operation(); } +function bootedSimulatorListResult( + request: AppleToolRequest, +): Readonly<{ stdout: string; stderr: string; exitCode: number }> { + return { + stdout: request.args.includes('list') + ? '{"devices":{"runtime":[{"udid":"apple-deployment-fact","state":"Booted"}]}}' + : '', + stderr: '', + exitCode: 0, + }; +} + function deploymentHost( appleDeployment: AppleAppDeploymentExecutor, - run = vi.fn(async (request: { args: readonly string[] }) => ({ + run = xcrunLikeRun((request) => ({ stdout: request.args.includes('list') ? '{"devices":{"runtime":[{"udid":"apple-deployment-fact","state":"Booted"}]}}' : '', @@ -262,6 +306,220 @@ test('exposes only fact-admitted Apple deployment operations', async () => { ).toEqual({}); }); +test('physical iOS install failure surfaces the devicectl Developer Mode hint', async () => { + const prepareArtifact = vi.fn(async () => ({ + installablePath: '/tmp/App.app', + bundleId: 'com.example.app', + appName: 'Example', + cleanup: vi.fn(async () => {}), + })); + const executor = { + prepareArtifact, + resolveAppBundleId: vi.fn(), + withInvalidatedAppResolutionCache: withoutInvalidatingAppResolutionCache, + } as AppleAppDeploymentExecutor; + const run = xcrunLikeRun((request) => + request.args.includes('install') + ? { + stdout: '', + stderr: 'Unable to install "com.example.app": Developer Mode is disabled on this device.', + exitCode: 1, + } + : { stdout: '', stderr: '', exitCode: 0 }, + ); + const host = deploymentHost(executor, run); + const device = appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'coredevice' }); + const operations = createAppleAppDeploymentOperations({ + host, + device, + signal: new AbortController().signal, + }); + + await assertRejectsAppError( + async () => + await operations.deployApp?.({ + app: 'com.example.app', + appPath: '/tmp/App.app', + replaceExisting: false, + }), + { + code: 'COMMAND_FAILED', + hint: /Developer Mode/, + normalizedMessage: /Developer Mode is disabled on this device/, + }, + ); + expect(run.mock.calls.some(([request]) => request.args.includes('install'))).toBe(true); +}); + +test('simulator install failure surfaces the simctl stderr excerpt with no devicectl hint', async () => { + const prepareArtifact = vi.fn(async () => ({ + installablePath: '/tmp/App.app', + bundleId: 'com.example.app', + appName: 'Example', + cleanup: vi.fn(async () => {}), + })); + const executor = { + prepareArtifact, + resolveAppBundleId: vi.fn(), + withInvalidatedAppResolutionCache: withoutInvalidatingAppResolutionCache, + } as AppleAppDeploymentExecutor; + const run = xcrunLikeRun((request) => + request.args.includes('install') + ? { + stdout: '', + stderr: 'Failed to install the requested application', + exitCode: 1, + } + : bootedSimulatorListResult(request), + ); + const host = deploymentHost(executor, run); + const device = appleDevice(); + const operations = createAppleAppDeploymentOperations({ + host, + device, + signal: new AbortController().signal, + }); + + await assertRejectsAppError( + async () => + await operations.deployApp?.({ + app: 'com.example.app', + appPath: '/tmp/App.app', + replaceExisting: false, + }), + { + code: 'COMMAND_FAILED', + normalizedMessage: /Failed to install the requested application/, + hint: null, + }, + ); + const [request] = run.mock.calls.find(([call]) => call.args.includes('install'))!; + expect(request.allowFailure).toBe(true); +}); + +test('simulator push failure surfaces the simctl stderr excerpt', async () => { + const executor = { + prepareArtifact: vi.fn(), + resolveAppBundleId: vi.fn(), + withInvalidatedAppResolutionCache: withoutInvalidatingAppResolutionCache, + } as AppleAppDeploymentExecutor; + const run = xcrunLikeRun((request) => + request.args.includes('push') + ? { + stdout: '', + stderr: 'Invalid device state: Booted', + exitCode: 1, + } + : bootedSimulatorListResult(request), + ); + const host = deploymentHost(executor, run); + const device = appleDevice(); + const operations = createAppleAppDeploymentOperations({ + host, + device, + signal: new AbortController().signal, + }); + + await assertRejectsAppError( + async () => await operations.sendPushNotification?.({ appId: 'com.example.app', payload: {} }), + { + code: 'COMMAND_FAILED', + normalizedMessage: /Invalid device state: Booted/, + hint: null, + }, + ); + const [request] = run.mock.calls.find(([call]) => call.args.includes('push'))!; + expect(request.allowFailure).toBe(true); +}); + +test('physical iOS uninstall failure surfaces the devicectl Developer Mode hint', async () => { + const resolveAppBundleId = vi.fn(async () => 'com.example.app'); + const executor = { + prepareArtifact: vi.fn(), + resolveAppBundleId, + withInvalidatedAppResolutionCache: withoutInvalidatingAppResolutionCache, + } as AppleAppDeploymentExecutor; + const run = xcrunLikeRun((request) => + request.args.includes('uninstall') + ? { + stdout: '', + stderr: + 'Unable to uninstall "com.example.app": Developer Mode is disabled on this device.', + exitCode: 1, + } + : { stdout: '', stderr: '', exitCode: 0 }, + ); + const host = deploymentHost(executor, run); + const device = appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'coredevice' }); + const operations = createAppleAppDeploymentOperations({ + host, + device, + signal: new AbortController().signal, + }); + + await assertRejectsAppError( + async () => + await operations.deployApp?.({ + app: 'com.example.app', + appPath: '/tmp/replacement.app', + replaceExisting: true, + }), + { + code: 'COMMAND_FAILED', + hint: /Developer Mode/, + normalizedMessage: /Developer Mode is disabled on this device/, + }, + ); + expect(run.mock.calls.some(([request]) => request.args.includes('uninstall'))).toBe(true); +}); + +test.each([ + [ + 'physical iOS CoreDevice', + appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'coredevice' }), + ], + ['iOS simulator', appleDevice()], +] as const)( + 'reinstall tolerates an already-missing %s uninstall with mixed-case stderr and still installs', + async (_name, device) => { + const resolveAppBundleId = vi.fn(async () => 'com.example.app'); + const prepareArtifact = vi.fn(async () => ({ + installablePath: '/tmp/App.app', + bundleId: 'com.example.app', + appName: 'Example', + cleanup: vi.fn(async () => {}), + })); + const executor = { + prepareArtifact, + resolveAppBundleId, + withInvalidatedAppResolutionCache: withoutInvalidatingAppResolutionCache, + } as AppleAppDeploymentExecutor; + const run = xcrunLikeRun((request) => { + if (request.args.includes('uninstall')) { + return { stdout: '', stderr: 'ERROR: App Not Installed', exitCode: 1 }; + } + return bootedSimulatorListResult(request); + }); + const host = deploymentHost(executor, run); + const operations = createAppleAppDeploymentOperations({ + host, + device, + signal: new AbortController().signal, + }); + + await expect( + operations.deployApp?.({ + app: 'com.example.app', + appPath: '/tmp/App.app', + replaceExisting: true, + }), + ).resolves.toMatchObject({ bundleId: 'com.example.app' }); + + expect(run.mock.calls.some(([request]) => request.args.includes('uninstall'))).toBe(true); + expect(run.mock.calls.some(([request]) => request.args.includes('install'))).toBe(true); + }, +); + test('preserves Apple reinstall partial-failure ordering', async () => { const order: string[] = []; const resolveAppBundleId = vi.fn(async () => { diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index 394310eb70..65f9d27973 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -6,10 +6,18 @@ import type { MaterializeAppSourceInput, PushNotificationInput, } from '@agent-device/contracts/app-deployment-runtime'; +import type { + AppleToolRequest, + HostCommandResult, +} from '@agent-device/contracts/platform-runtime-host'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { requireExecSuccess } from '@agent-device/host-kit/command'; +import { isMissingAppErrorOutput } from '../core/physical-device-apps.ts'; +import { IOS_DEVICE_INSTALL_TIMEOUT_MS } from '../core/config.ts'; +import { IOS_DEVICECTL_DEFAULT_HINT, resolveIosDevicectlHint } from '../core/devicectl.ts'; import { ensureAppleReady } from '../readiness/runtime.ts'; import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; @@ -135,7 +143,8 @@ async function installAppleApp( signal: AbortSignal, ): Promise { await ensureAppleReady(host, device, signal); - const result = await host.appleTools.run( + await runAppleTool( + host, device.kind === 'simulator' ? { tool: 'simctl', @@ -144,11 +153,11 @@ async function installAppleApp( : { tool: 'devicectl', args: ['device', 'install', 'app', '--device', device.id, installablePath], - timeoutMs: 120_000, + timeoutMs: IOS_DEVICE_INSTALL_TIMEOUT_MS, }, signal, + 'Apple app install failed', ); - assertAppleToolSuccess(result, 'Apple app install failed'); } async function uninstallAppleApp( @@ -158,22 +167,24 @@ async function uninstallAppleApp( signal: AbortSignal, ): Promise { await ensureAppleReady(host, device, signal); - const result = await host.appleTools.run( + await runAppleTool( + host, device.kind === 'simulator' ? { tool: 'simctl', args: scopeSimctlArgsForDevice(device, ['uninstall', device.id, bundleId]), - allowFailure: true, } : { tool: 'devicectl', args: ['device', 'uninstall', 'app', '--device', device.id, bundleId], - allowFailure: true, }, signal, + `Apple app uninstall failed for ${bundleId}`, + { + tolerate: (result) => + isMissingAppErrorOutput(`${result.stdout}\n${result.stderr}`.toLowerCase()), + }, ); - if (result.exitCode === 0 || isMissingAppOutput(`${result.stdout}\n${result.stderr}`)) return; - assertAppleToolSuccess(result, `Apple app uninstall failed for ${bundleId}`); } async function pushAppleNotification( @@ -192,38 +203,47 @@ async function pushAppleNotification( }); try { await payload.writeText(`${JSON.stringify(input.payload)}\n`); - const result = await host.appleTools.run( + await runAppleTool( + host, { tool: 'simctl', args: scopeSimctlArgsForDevice(device, ['push', device.id, input.appId, payload.path]), }, signal, + 'Apple push notification failed', ); - assertAppleToolSuccess(result, 'Apple push notification failed'); } finally { await payload[Symbol.asyncDispose](); } } -function isMissingAppOutput(output: string): boolean { - const normalized = output.toLowerCase(); - return ( - normalized.includes('not installed') || - normalized.includes('not found') || - normalized.includes('no such file') - ); -} - -function assertAppleToolSuccess( - result: Readonly<{ stdout: string; stderr: string; exitCode: number | null }>, +/** + * The one request path this module uses to run a tolerated Apple tool call: it forces + * `allowFailure`, then guards the result itself through `requireExecSuccess` so a non-zero + * exit always throws the same COMMAND_FAILED shape as every other exec call site, with the + * caller's curated message. A devicectl failure gets the same Developer Mode, + * developer-disk-image, and pairing hints the other devicectl call sites attach. `tolerate` + * lets a caller accept a specific non-zero result (uninstall's "already missing" case) + * without losing that guard for every other outcome. + */ +async function runAppleTool( + host: PlatformRuntimeHost, + request: Omit, + signal: AbortSignal, message: string, -): void { - if (result.exitCode === 0) return; - throw new AppError('COMMAND_FAILED', message, { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + options?: Readonly<{ tolerate?: (result: HostCommandResult) => boolean }>, +): Promise { + const result = await host.appleTools.run({ ...request, allowFailure: true }, signal); + if (options?.tolerate?.(result)) return result; + return requireExecSuccess(result, message, (failed) => ({ + cmd: 'xcrun', + args: [request.tool, ...request.args], + ...(request.tool === 'devicectl' + ? { + hint: resolveIosDevicectlHint(failed.stdout, failed.stderr) ?? IOS_DEVICECTL_DEFAULT_HINT, + } + : {}), + })); } function appleDeployFact(device: DeviceInfo): RuntimeOperationFact { diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index 7495497e7a..b3adbb26c4 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -508,9 +508,9 @@ const COMMAND_COVERAGE_DECLARATIONS = { 'typed broadcast extras are persisted by the fixture receiver and rendered after refresh', ), iosSimulator: iosSimulator.contract( - 'packages/platform-apple/src/core/__tests__/apps.test.ts', - 'pushIosNotification uses simctl push with temporary payload file', - 'simctl push dispatch; fixture has no notification entitlement or UI oracle', + 'packages/platform-apple/src/deployment/runtime.test.ts', + 'exposes only fact-admitted Apple deployment operations', + 'simctl push dispatch through the shared Apple deployment operations; fixture has no notification entitlement or UI oracle', ), macos: macos.contract( 'packages/platform-apple/src/deployment/runtime.test.ts',