diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 267e9c5949..e85144a58e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -151,6 +151,30 @@ export type SettingOptions = { longitude?: number; }; +/** The settings a simulator changes through `simctl` alone, whichever host runs that `simctl`. */ +type SimctlSetting = 'appearance' | 'permission' | 'location'; + +/** + * Runs one `simctl` argv on the simulator. It resolves with the output when `simctl` exits 0 and + * rejects with a `COMMAND_FAILED` AppError whose `details.stderr` is the `simctl` stderr otherwise. + */ +type SimctlSettingRunner = ( + args: string[], +) => Promise<{ readonly stdout: string; readonly stderr: string }>; + +/** One simulator setting the Apple `simctl` plan changes, whichever host runs that `simctl`. */ +export type SimctlSettingRequest = { + runSimctl: SimctlSettingRunner; + /** The simulator UDID `simctl` addresses, or `booted` where the runner reaches one simulator. */ + udid: string; + /** The device id a refusal reports to the caller, which `udid` is not on every runner. */ + deviceId: string; + setting: SimctlSetting; + state: string; + appBundleId?: string; + options?: SettingOptions; +}; + /** * Whether naming an app for one mutation can mean anything on one target. * diff --git a/packages/platform-apple/src/core/__tests__/simctl-settings.test.ts b/packages/platform-apple/src/core/__tests__/simctl-settings.test.ts index 9793fff664..04c1f96c19 100644 --- a/packages/platform-apple/src/core/__tests__/simctl-settings.test.ts +++ b/packages/platform-apple/src/core/__tests__/simctl-settings.test.ts @@ -1,6 +1,7 @@ import { expect, test, vi } from 'vitest'; +import type { SimctlSettingRequest } from '@agent-device/contracts/settings'; import { AppError } from '@agent-device/kernel/errors'; -import { applySimctlSetting, type SimctlSettingRequest } from '../simctl-settings.ts'; +import { applySimctlSetting } from '../simctl-settings.ts'; function recordingRunner(outputs: Array<{ stdout: string; stderr: string } | AppError> = []) { const queue = [...outputs]; @@ -15,7 +16,14 @@ function request( runSimctl: SimctlSettingRequest['runSimctl'], overrides: Partial, ): SimctlSettingRequest { - return { runSimctl, udid: 'SIM-1', setting: 'appearance', state: 'dark', ...overrides }; + return { + runSimctl, + udid: 'SIM-1', + deviceId: 'SIM-1', + setting: 'appearance', + state: 'dark', + ...overrides, + }; } test('every simctl argv addresses the udid the runner was given', async () => { @@ -72,6 +80,32 @@ test('a privacy service the runtime refuses is unsupported; other failures pass await expect(applySimctlSetting(grant)).rejects.toBe(failed); }); +test('a refused privacy service reports the device id the caller names, not the udid', async () => { + const refused = new AppError('COMMAND_FAILED', 'simctl exited with code 1', { + stderr: 'Failed to grant access to com.example.app\nOperation not permitted', + }); + const runSimctl = recordingRunner([refused]); + + await expect( + applySimctlSetting( + request(runSimctl, { + udid: 'booted', + deviceId: 'limrun:ios:lease-a', + setting: 'permission', + state: 'grant', + appBundleId: 'com.example.app', + options: { permissionTarget: 'notifications' }, + }), + ), + ).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { deviceId: 'limrun:ios:lease-a', appBundleId: 'com.example.app' }, + }); + expect(runSimctl.mock.calls).toEqual([ + [['privacy', 'booted', 'grant', 'notifications', 'com.example.app']], + ]); +}); + test('an app-scoped setting without an app refuses before running simctl', async () => { const runSimctl = recordingRunner(); diff --git a/packages/platform-apple/src/core/app-settings.ts b/packages/platform-apple/src/core/app-settings.ts index 237411b8bd..adfaefe79b 100644 --- a/packages/platform-apple/src/core/app-settings.ts +++ b/packages/platform-apple/src/core/app-settings.ts @@ -146,6 +146,7 @@ export async function setIosSetting( return await applySimctlSetting({ runSimctl: (args) => runSimctlForDevice(device, args), udid: device.id, + deviceId: device.id, setting: normalized, state, appBundleId, diff --git a/packages/platform-apple/src/core/simctl-settings.ts b/packages/platform-apple/src/core/simctl-settings.ts index 942c73c052..98f358a37a 100644 --- a/packages/platform-apple/src/core/simctl-settings.ts +++ b/packages/platform-apple/src/core/simctl-settings.ts @@ -4,32 +4,11 @@ import { parsePermissionAction, parsePermissionTarget, parseSettingState, - type SettingOptions, + type SimctlSettingRequest, } from '@agent-device/contracts/settings'; import { AppError, sessionAppRequiredDetails } from '@agent-device/kernel/errors'; import { requireLocationCoordinates } from '@agent-device/kernel/location-coordinates'; -/** The settings a simulator changes through `simctl` alone, whichever host runs that `simctl`. */ -type SimctlSetting = 'appearance' | 'permission' | 'location'; - -/** - * Runs one `simctl` argv on the simulator. It resolves with the output when `simctl` exits 0 and - * rejects with a `COMMAND_FAILED` AppError whose `details.stderr` is the `simctl` stderr otherwise. - */ -type SimctlSettingRunner = ( - args: string[], -) => Promise<{ readonly stdout: string; readonly stderr: string }>; - -export type SimctlSettingRequest = { - runSimctl: SimctlSettingRunner; - /** The simulator UDID `simctl` addresses, or `booted` where the runner reaches one simulator. */ - udid: string; - setting: SimctlSetting; - state: string; - appBundleId?: string; - options?: SettingOptions; -}; - /** Changes one simulator setting through `simctl`; the Apple package owns this plan for every runner. */ export async function applySimctlSetting( request: SimctlSettingRequest, @@ -68,7 +47,7 @@ async function setAppearance({ runSimctl, udid, state }: SimctlSettingRequest): } async function setPermission(request: SimctlSettingRequest): Promise { - const { runSimctl, udid, state, options } = request; + const { runSimctl, udid, deviceId, state, options } = request; const appBundleId = requireAppBundleId(request); const permissionAction = parsePermissionAction(state); const action = permissionAction === 'deny' ? 'revoke' : permissionAction; @@ -77,7 +56,7 @@ async function setPermission(request: SimctlSettingRequest): Promise { await runSimctl(['privacy', udid, action, target, appBundleId]); } catch (error) { if (!isIosPrivacyServiceRefusal(error)) throw error; - throw iosPrivacyServiceRefusedError(action, target, appBundleId, udid, error); + throw iosPrivacyServiceRefusedError(action, target, appBundleId, deviceId, error); } } diff --git a/packages/provider-limrun/src/ios-settings.test.ts b/packages/provider-limrun/src/ios-settings.test.ts index 432bbda85c..86515db4c7 100644 --- a/packages/provider-limrun/src/ios-settings.test.ts +++ b/packages/provider-limrun/src/ios-settings.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi } from 'vitest'; +import type { SimctlSettingRequest } from '@agent-device/contracts/settings'; import type { LimrunIosSession } from './ios.ts'; -import type { LimrunIosSimctlSettingRequest } from './runtime-dependencies.ts'; import { setLimrunIosSetting } from './ios-settings.ts'; function sessionWithClient(results: Array<{ code: number; stdout: string; stderr: string }> = []) { @@ -12,7 +12,7 @@ function sessionWithClient(results: Array<{ code: number; stdout: string; stderr softReset: vi.fn(async (_bundleId: string, _options?: unknown) => ({})), terminateApp: vi.fn(async (_bundleId: string) => {}), }; - const applySimctlSetting = vi.fn(async (request: LimrunIosSimctlSettingRequest) => { + const applySimctlSetting = vi.fn(async (request: SimctlSettingRequest) => { const output = await request.runSimctl(['ui', request.udid, 'appearance']); return { output }; }); @@ -27,7 +27,7 @@ function sessionWithClient(results: Array<{ code: number; stdout: string; stderr return { session, client, applySimctlSetting }; } -test('simctl settings run the Apple plan on the booted simulator with the resolved app', async () => { +test('simctl settings run the Apple plan on the booted simulator, reporting the session device', async () => { const { session, client, applySimctlSetting } = sessionWithClient([ { code: 0, stdout: 'dark\n', stderr: '' }, ]); @@ -38,6 +38,7 @@ test('simctl settings run the Apple plan on the booted simulator with the resolv expect(applySimctlSetting).toHaveBeenCalledWith({ runSimctl: expect.any(Function), udid: 'booted', + deviceId: 'limrun:ios:settings', setting: 'permission', state: 'grant', appBundleId: 'resolved.camera-app', diff --git a/packages/provider-limrun/src/ios-settings.ts b/packages/provider-limrun/src/ios-settings.ts index d6c245c2ba..7f72d1642f 100644 --- a/packages/provider-limrun/src/ios-settings.ts +++ b/packages/provider-limrun/src/ios-settings.ts @@ -22,6 +22,7 @@ export async function setLimrunIosSetting( return await session.dependencies.ios.applySimctlSetting({ runSimctl: (args) => runLimrunSimctl(session, args), udid: 'booted', + deviceId: session.device.id, setting: normalized, state, appBundleId: diff --git a/packages/provider-limrun/src/runtime-dependencies.ts b/packages/provider-limrun/src/runtime-dependencies.ts index 72880cea23..5ea353e70b 100644 --- a/packages/provider-limrun/src/runtime-dependencies.ts +++ b/packages/provider-limrun/src/runtime-dependencies.ts @@ -4,7 +4,7 @@ import type { Interactor } from '@agent-device/contracts/interactor-types'; import type { AndroidInputOwner } from '@agent-device/contracts/android-input-ownership'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AppError } from '@agent-device/kernel/errors'; -import type { SettingOptions } from '@agent-device/contracts/settings'; +import type { SimctlSettingRequest } from '@agent-device/contracts/settings'; export type LimrunAdbCommandOptions = { allowFailure?: boolean; @@ -141,23 +141,9 @@ export type LimrunHostAdapter = { downloadFile(options: LimrunFileDownload): Promise; }; -/** - * One simulator setting the Apple package changes through `simctl`, in that package's terms. The - * composition root supplies the Apple plan, so the provider does not import a platform package. - */ -export type LimrunIosSimctlSettingRequest = { - runSimctl(args: string[]): Promise<{ readonly stdout: string; readonly stderr: string }>; - udid: string; - setting: 'appearance' | 'permission' | 'location'; - state: string; - appBundleId?: string; - options?: SettingOptions; -}; - export type LimrunIosRuntimeAdapter = { - applySimctlSetting( - request: LimrunIosSimctlSettingRequest, - ): Promise | void>; + /** Runs the Apple simctl plan, supplied by the composition root so this provider imports no platform package. */ + applySimctlSetting(request: SimctlSettingRequest): Promise | void>; resolveAppAlias(app: string): Promise; readBundleAppName(appPath: string): Promise; }; diff --git a/src/sdk/limrun-runtime-dependencies.test.ts b/src/sdk/limrun-runtime-dependencies.test.ts index 16f3265b7b..73498587e5 100644 --- a/src/sdk/limrun-runtime-dependencies.test.ts +++ b/src/sdk/limrun-runtime-dependencies.test.ts @@ -37,15 +37,8 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => })); vi.mock('@agent-device/platform-apple/simctl-settings', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - applySimctlSetting: vi.fn(async (request: Parameters[0]) => { - moduleLoads.appleSimctlSettings += 1; - return await actual.applySimctlSetting(request); - }), - }; + moduleLoads.appleSimctlSettings += 1; + return await importOriginal(); }); vi.mock('@agent-device/platform-apple/install-artifact', async (importOriginal) => ({ @@ -254,22 +247,28 @@ test('the adb invocation adapters address through the platform builders', async assert.deepEqual(serializeAndroidAdbInvocation(host), ['disconnect', '127.0.0.1:62001']); }); -test('Limrun iOS settings run the Apple simctl plan, loaded on the first setting', async () => { +test('Limrun iOS settings evaluate the Apple simctl plan module on the first setting', async () => { const { createLimrunRuntimeDependencies } = await import('./limrun-runtime-dependencies.ts'); const dependencies = createLimrunRuntimeDependencies(); const runSimctl = vi.fn(async (_args: string[]) => ({ stdout: '', stderr: '' })); - - await dependencies.ios.applySimctlSetting({ + const grantPhotos = { runSimctl, udid: 'booted', + deviceId: 'limrun:ios:lease-a', setting: 'permission', state: 'grant', appBundleId: 'com.example.app', options: { permissionTarget: 'photos', permissionMode: 'limited' }, - }); + } as const; + + assert.equal(moduleLoads.appleSimctlSettings, 0); + + await dependencies.ios.applySimctlSetting(grantPhotos); + await dependencies.ios.applySimctlSetting(grantPhotos); assert.equal(moduleLoads.appleSimctlSettings, 1); assert.deepEqual(runSimctl.mock.calls, [ [['privacy', 'booted', 'grant', 'photos-add', 'com.example.app']], + [['privacy', 'booted', 'grant', 'photos-add', 'com.example.app']], ]); });