diff --git a/packages/platform-apple/src/core/__tests__/app-settings.test.ts b/packages/platform-apple/src/core/__tests__/app-settings.test.ts index df416ceb9f..c7705a6742 100644 --- a/packages/platform-apple/src/core/__tests__/app-settings.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-settings.test.ts @@ -300,54 +300,18 @@ test('setIosSetting rejects unsupported macOS wifi setting with explicit subset ); }); -test('setIosSetting location set sends simulator latitude and longitude', async () => { +test('setIosSetting location runs the simctl plan on the simulator udid and returns its result', async () => { mockEnsureBootedSimulator.mockResolvedValue(undefined); await withFakeAppleTool( () => '', async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'location', 'set', undefined, { + const result = await setIosSetting(IOS_TEST_SIMULATOR, 'location', 'set', undefined, { latitude: 37.3349, longitude: -122.009, }); assert.deepEqual(calls, [['simctl', 'location', 'sim-1', 'set', '37.3349,-122.009']]); - }, - ); -}); - -test('setIosSetting permission requires an app in session with the published reason', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - return unexpectedArgs(args); - }, - async () => { - await assertRejectsAppError( - () => - setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', undefined, { - permissionTarget: 'camera', - }), - { - code: 'INVALID_ARGS', - reason: PRE_DISPATCH_REFUSAL_REASONS.sessionAppRequired, - dispatched: 'no', - }, - ); - }, - ); -}); - -test('setIosSetting location refuses an appless session with the published reason', async () => { - mockEnsureBootedSimulator.mockResolvedValue(undefined); - - await withFakeAppleTool( - (args) => unexpectedArgs(args), - async () => { - await assertRejectsAppError(() => setIosSetting(IOS_TEST_SIMULATOR, 'location', 'on'), { - code: 'INVALID_ARGS', - reason: PRE_DISPATCH_REFUSAL_REASONS.sessionAppRequired, - dispatched: 'no', - }); + assert.deepEqual(result, { latitude: 37.3349, longitude: -122.009 }); }, ); }); @@ -370,76 +334,21 @@ test('setIosSetting clear-app-state refuses an appless session with the publishe ); }); -test('setIosSetting appearance toggle flips current simulator appearance', async () => { +test('setIosSetting appearance runs the simctl plan on the simulator udid and reads its output', async () => { + mockEnsureBootedSimulator.mockResolvedValue(undefined); + await withFakeAppleTool( (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; if (args.join(' ') === 'simctl ui sim-1 appearance') return 'dark'; if (args.join(' ') === 'simctl ui sim-1 appearance light') return ''; return unexpectedArgs(args); }, async ({ calls }) => { await setIosSetting(IOS_TEST_SIMULATOR, 'appearance', 'toggle'); - const flat = calls.map((args) => args.join(' ')); - assert.equal(flat.includes('simctl ui sim-1 appearance'), true, flat.join('; ')); - assert.equal(flat.includes('simctl ui sim-1 appearance light'), true, flat.join('; ')); - }, - ); -}); - -test('setIosSetting appearance toggle rejects unsupported current appearance output', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args.join(' ') === 'simctl ui sim-1 appearance') return 'unsupported'; - return ''; - }, - async () => { - await assertRejectsAppError(() => setIosSetting(IOS_TEST_SIMULATOR, 'appearance', 'toggle'), { - code: 'COMMAND_FAILED', - message: /Unable to determine current iOS appearance/, - }); - }, - ); -}); - -test('setIosSetting permission grant calendar uses simctl privacy calendar target', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args.join(' ') === 'simctl privacy sim-1 grant calendar com.example.app') return ''; - return unexpectedArgs(args); - }, - async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', 'com.example.app', { - permissionTarget: 'calendar', - }); - const flat = calls.map((args) => args.join(' ')); - assert.equal( - flat.includes('simctl privacy sim-1 grant calendar com.example.app'), - true, - flat.join('; '), - ); - }, - ); -}); - -test('setIosSetting permission grant all passes all through as one simctl call', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args.join(' ') === 'simctl privacy sim-1 grant all com.example.app') return ''; - return unexpectedArgs(args); - }, - async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', 'com.example.app', { - permissionTarget: 'all', - }); - const flat = calls.map((args) => args.join(' ')); - assert.deepEqual( - flat.filter((line) => line.includes('privacy sim-1')), - ['simctl privacy sim-1 grant all com.example.app'], - ); + assert.deepEqual(calls, [ + ['simctl', 'ui', 'sim-1', 'appearance'], + ['simctl', 'ui', 'sim-1', 'appearance', 'light'], + ]); }, ); }); @@ -538,127 +447,11 @@ test('setIosSetting reset-keychain rejects unsupported state', async () => { ); }); -test('setIosSetting permission grant photos limited maps to photos-add', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args.join(' ') === 'simctl privacy sim-1 grant photos-add com.example.app') return ''; - return unexpectedArgs(args); - }, - async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', 'com.example.app', { - permissionTarget: 'photos', - permissionMode: 'limited', - }); - const flat = calls.map((args) => args.join(' ')); - assert.equal( - flat.includes('simctl privacy sim-1 grant photos-add com.example.app'), - true, - flat.join('; '), - ); - }, - ); -}); - -test('setIosSetting permission rejects mode for non-photos target', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - return unexpectedArgs(args); - }, - async () => { - await assertRejectsAppError( - () => - setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', 'com.example.app', { - permissionTarget: 'camera', - permissionMode: 'limited', - }), - { code: 'INVALID_ARGS', message: /mode is only supported for photos/i }, - ); - }, - ); -}); - -test('setIosSetting permission reset notifications fails targeted when direct reset is blocked', async () => { - // A blocked notifications reset must not fall back to `reset all`: a - // notifications-only reset would clear microphone, location, and other - // grants. The targeted reset fails instead, leaving the earlier grant in place. - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args.join(' ') === 'simctl privacy sim-1 grant microphone com.example.app') return ''; - if (args.join(' ') === 'simctl privacy sim-1 reset notifications com.example.app') { - return { stderr: 'Failed to reset access\nOperation not permitted', exitCode: 1 }; - } - return unexpectedArgs(args); - }, - async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', 'com.example.app', { - permissionTarget: 'microphone', - }); - await assertRejectsAppError( - () => - setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'reset', 'com.example.app', { - permissionTarget: 'notifications', - }), - { - code: 'UNSUPPORTED_OPERATION', - message: /does not support resetting notifications permission/i, - }, - ); - const flat = calls.map((args) => args.join(' ')); - assert.equal( - flat.includes('simctl privacy sim-1 reset notifications com.example.app'), - true, - flat.join('; '), - ); - assert.equal( - flat.some((line) => line.includes('reset all com.example.app')), - false, - flat.join('; '), - ); - assert.equal( - flat.includes('simctl privacy sim-1 grant microphone com.example.app'), - true, - flat.join('; '), - ); - }, - ); -}); - -test('setIosSetting permission grant camera needs no capability probe', async () => { - // Xcode 26 omits `camera` from `simctl privacy help` while still changing it, so a - // help-derived gate refused a service every runtime here serves. The privacy call is - // itself the probe, so nothing else may be issued for a grant. - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args.join(' ') === 'simctl privacy sim-1 grant camera com.example.app') return ''; - return unexpectedArgs(args); - }, - async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', 'com.example.app', { - permissionTarget: 'camera', - }); - const flat = calls.map((args) => args.join(' ')); - assert.equal( - flat.includes('simctl privacy sim-1 grant camera com.example.app'), - true, - flat.join('; '), - ); - assert.equal( - flat.some((line) => line.includes('privacy help')), - false, - flat.join('; '), - ); - }, - ); -}); +test('setIosSetting permission runs the simctl plan on the simulator udid and surfaces its refusal', async () => { + mockEnsureBootedSimulator.mockResolvedValue(undefined); -test('setIosSetting permission deny notifications returns unsupported on runtimes that block it', async () => { await withFakeAppleTool( (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; if (args.join(' ') === 'simctl privacy sim-1 revoke notifications com.example.app') { return { stderr: 'Failed to revoke access\nOperation not permitted', exitCode: 1 }; } @@ -675,45 +468,9 @@ test('setIosSetting permission deny notifications returns unsupported on runtime message: /does not support setting notifications permission/i, }, ); - const flat = calls.map((args) => args.join(' ')); - assert.equal( - flat.includes('simctl privacy sim-1 revoke notifications com.example.app'), - true, - flat.join('; '), - ); - }, - ); -}); - -test('setIosSetting permission reports a runtime-refused service as unsupported', async () => { - // A service the runtime cannot change answers EPERM, and Xcode 26 words grant/revoke - // failures as "Failed to set access" — not "failed to grant access" — for both a real - // service it withheld and a name it does not know at all. - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args.join(' ') === 'simctl privacy sim-1 grant calendar com.example.app') { - return { stderr: 'Failed to set access\nOperation not permitted', exitCode: 1 }; - } - return unexpectedArgs(args); - }, - async ({ calls }) => { - await assertRejectsAppError( - () => - setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', 'com.example.app', { - permissionTarget: 'calendar', - }), - { - code: 'UNSUPPORTED_OPERATION', - message: /does not support setting calendar permission/i, - }, - ); - const flat = calls.map((args) => args.join(' ')); - assert.equal( - flat.some((line) => line.includes('privacy help')), - false, - flat.join('; '), - ); + assert.deepEqual(calls, [ + ['simctl', 'privacy', 'sim-1', 'revoke', 'notifications', 'com.example.app'], + ]); }, ); }); 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 04c1f96c19..e414f5af5b 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,6 @@ import { expect, test, vi } from 'vitest'; import type { SimctlSettingRequest } from '@agent-device/contracts/settings'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors'; import { applySimctlSetting } from '../simctl-settings.ts'; function recordingRunner(outputs: Array<{ stdout: string; stderr: string } | AppError> = []) { @@ -26,6 +26,19 @@ function request( }; } +function argvs(runSimctl: ReturnType): string[][] { + return runSimctl.mock.calls.map(([args]) => args); +} + +async function appErrorFrom(pending: Promise): Promise { + const rejection = await pending.then( + () => expect.unreachable('expected the setting to reject'), + (error: unknown) => error, + ); + expect(rejection).toBeInstanceOf(AppError); + return rejection as AppError; +} + test('every simctl argv addresses the udid the runner was given', async () => { const runSimctl = recordingRunner([{ stdout: 'light\n', stderr: '' }]); @@ -46,7 +59,7 @@ test('every simctl argv addresses the udid the runner was given', async () => { }), ); - expect(runSimctl.mock.calls.map(([args]) => args)).toEqual([ + expect(argvs(runSimctl)).toEqual([ ['ui', 'SIM-1', 'appearance'], ['ui', 'SIM-1', 'appearance', 'dark'], ['privacy', 'SIM-1', 'revoke', 'photos-add', 'com.example.app'], @@ -55,6 +68,71 @@ test('every simctl argv addresses the udid the runner was given', async () => { expect(location).toEqual({ latitude: 1, longitude: 2 }); }); +test.for([ + { current: 'dark', target: 'light' }, + { current: 'light', target: 'dark' }, +])('appearance toggle reads $current and sets $target', async ({ current, target }) => { + const runSimctl = recordingRunner([{ stdout: `${current}\n`, stderr: '' }]); + + await applySimctlSetting(request(runSimctl, { state: 'toggle' })); + + expect(argvs(runSimctl)).toEqual([ + ['ui', 'SIM-1', 'appearance'], + ['ui', 'SIM-1', 'appearance', target], + ]); +}); + +test('appearance toggle refuses a current appearance that is neither light nor dark', async () => { + const runSimctl = recordingRunner([{ stdout: 'unsupported', stderr: '' }]); + + const error = await appErrorFrom(applySimctlSetting(request(runSimctl, { state: 'toggle' }))); + + expect(error).toMatchObject({ + code: 'COMMAND_FAILED', + message: 'Unable to determine current iOS appearance for toggle', + details: { stdout: 'unsupported', stderr: '' }, + }); + expect(argvs(runSimctl)).toEqual([['ui', 'SIM-1', 'appearance']]); +}); + +test('location set sends one latitude,longitude argument and returns the coordinates', async () => { + const runSimctl = recordingRunner(); + + const result = await applySimctlSetting( + request(runSimctl, { + setting: 'location', + state: 'set', + options: { latitude: 37.3349, longitude: -122.009 }, + }), + ); + + expect(argvs(runSimctl)).toEqual([['location', 'SIM-1', 'set', '37.3349,-122.009']]); + expect(result).toEqual({ latitude: 37.3349, longitude: -122.009 }); +}); + +test.for([ + { options: { permissionTarget: 'calendar' }, service: 'calendar' }, + { options: { permissionTarget: 'all' }, service: 'all' }, + { options: { permissionTarget: 'camera' }, service: 'camera' }, + { options: { permissionTarget: 'photos', permissionMode: 'limited' }, service: 'photos-add' }, +])( + 'grant $options.permissionTarget is one simctl privacy $service call with no capability probe', + async ({ options, service }) => { + const runSimctl = recordingRunner(); + + await applySimctlSetting( + request(runSimctl, { + setting: 'permission', + state: 'grant', + appBundleId: 'com.example.app', + options, + }), + ); + + expect(argvs(runSimctl)).toEqual([['privacy', 'SIM-1', 'grant', service, 'com.example.app']]); + }, +); + test('a privacy service the runtime refuses is unsupported; other failures pass through', async () => { const refused = new AppError('COMMAND_FAILED', 'simctl exited with code 1', { stderr: 'Failed to grant access to com.example.app\nOperation not permitted', @@ -101,27 +179,112 @@ test('a refused privacy service reports the device id the caller names, not the 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']], + expect(argvs(runSimctl)).toEqual([ + ['privacy', 'booted', 'grant', 'notifications', 'com.example.app'], ]); }); -test('an app-scoped setting without an app refuses before running simctl', async () => { - const runSimctl = recordingRunner(); +test.for([ + { state: 'deny', action: 'revoke', target: 'notifications', wording: 'Failed to revoke access' }, + { state: 'grant', action: 'grant', target: 'calendar', wording: 'Failed to set access' }, +])( + '$state $target refused as $wording is unsupported, with no capability probe', + async ({ state, action, target, wording }) => { + const refused = new AppError('COMMAND_FAILED', 'simctl exited with code 1', { + stderr: `${wording}\nOperation not permitted`, + }); + const runSimctl = recordingRunner([refused]); - for (const [setting, state] of [ - ['permission', 'grant'], - ['location', 'on'], - ] as const) { - await expect( + const error = await appErrorFrom( + applySimctlSetting( + request(runSimctl, { + setting: 'permission', + state, + appBundleId: 'com.example.app', + options: { permissionTarget: target }, + }), + ), + ); + + expect(error).toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + message: `iOS simulator does not support setting ${target} permission via simctl privacy on this runtime.`, + details: { deviceId: 'SIM-1', appBundleId: 'com.example.app' }, + cause: refused, + }); + expect(argvs(runSimctl)).toEqual([['privacy', 'SIM-1', action, target, 'com.example.app']]); + }, +); + +test('a refused targeted reset fails instead of a reset all that would clear earlier grants', async () => { + const refused = new AppError('COMMAND_FAILED', 'simctl exited with code 1', { + stderr: 'Failed to reset access\nOperation not permitted', + }); + const runSimctl = recordingRunner([{ stdout: '', stderr: '' }, refused]); + const permission = (state: string, permissionTarget: string) => + request(runSimctl, { + setting: 'permission', + state, + appBundleId: 'com.example.app', + options: { permissionTarget }, + }); + + await applySimctlSetting(permission('grant', 'microphone')); + const error = await appErrorFrom(applySimctlSetting(permission('reset', 'notifications'))); + + expect(error).toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + message: + 'iOS simulator does not support resetting notifications permission via simctl privacy on this runtime.', + details: { deviceId: 'SIM-1', appBundleId: 'com.example.app' }, + cause: refused, + }); + expect(argvs(runSimctl)).toEqual([ + ['privacy', 'SIM-1', 'grant', 'microphone', 'com.example.app'], + ['privacy', 'SIM-1', 'reset', 'notifications', 'com.example.app'], + ]); +}); + +test.for([ + { setting: 'permission', state: 'grant' }, + { setting: 'location', state: 'on' }, +] as const)( + '$setting $state without an app refuses before running simctl', + async ({ setting, state }) => { + const runSimctl = recordingRunner(); + + const error = await appErrorFrom( applySimctlSetting( request(runSimctl, { setting, state, options: { permissionTarget: 'camera' } }), ), - ).rejects.toMatchObject({ + ); + + expect(error).toMatchObject({ code: 'INVALID_ARGS', message: `${setting} setting requires an active app in session`, - details: { reason: 'session_app_required' }, + details: { reason: PRE_DISPATCH_REFUSAL_REASONS.sessionAppRequired, dispatched: 'no' }, }); - } + expect(runSimctl).not.toHaveBeenCalled(); + }, +); + +test('a permission mode on a target other than photos refuses before running simctl', async () => { + const runSimctl = recordingRunner(); + + const error = await appErrorFrom( + applySimctlSetting( + request(runSimctl, { + setting: 'permission', + state: 'grant', + appBundleId: 'com.example.app', + options: { permissionTarget: 'camera', permissionMode: 'limited' }, + }), + ), + ); + + expect(error).toMatchObject({ + code: 'INVALID_ARGS', + message: 'Permission mode is only supported for photos. Received: limited.', + }); expect(runSimctl).not.toHaveBeenCalled(); });