diff --git a/examples/test-app/app.config.js b/examples/test-app/app.config.js index b51fc3090..123ce3b96 100644 --- a/examples/test-app/app.config.js +++ b/examples/test-app/app.config.js @@ -20,6 +20,7 @@ module.exports = { buildCacheProvider: { plugin: 'expo-build-disk-cache' }, plugins: [ 'expo-router', + 'expo-secure-store', [ 'expo-audio', { diff --git a/examples/test-app/package.json b/examples/test-app/package.json index 8e02d6a67..d7c0e28e8 100644 --- a/examples/test-app/package.json +++ b/examples/test-app/package.json @@ -20,6 +20,7 @@ "expo-linking": "56.0.14", "expo-modules-core": "56.0.17", "expo-router": "~56.2.11", + "expo-secure-store": "~56.0.4", "expo-status-bar": "~56.0.4", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/examples/test-app/pnpm-lock.yaml b/examples/test-app/pnpm-lock.yaml index b63effb15..9cb0f6688 100644 --- a/examples/test-app/pnpm-lock.yaml +++ b/examples/test-app/pnpm-lock.yaml @@ -52,6 +52,9 @@ importers: expo-router: specifier: ~56.2.11 version: 56.2.11(6045d650059d773517c662a96f3affcd) + expo-secure-store: + specifier: ~56.0.4 + version: 56.0.4(expo@56.0.12) expo-status-bar: specifier: ~56.0.4 version: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) @@ -1695,6 +1698,11 @@ packages: react-server-dom-webpack: optional: true + expo-secure-store@56.0.4: + resolution: {integrity: sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ==} + peerDependencies: + expo: '*' + expo-server@56.0.5: resolution: {integrity: sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==} engines: {node: '>=20.16.0'} @@ -4956,6 +4964,10 @@ snapshots: - react-native-worklets - supports-color + expo-secure-store@56.0.4(expo@56.0.12): + dependencies: + expo: 56.0.12(0f7f54a25518a84adeb79a7b6062b258) + expo-server@56.0.5: {} expo-status-bar@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): diff --git a/examples/test-app/src/screens/AutomationLabScreen.tsx b/examples/test-app/src/screens/AutomationLabScreen.tsx index 029436fa1..41ddf6944 100644 --- a/examples/test-app/src/screens/AutomationLabScreen.tsx +++ b/examples/test-app/src/screens/AutomationLabScreen.tsx @@ -14,10 +14,17 @@ import { } from 'react-native'; import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio'; import { requireOptionalNativeModule } from 'expo-modules-core'; +import * as SecureStore from 'expo-secure-store'; import { ActionButton, ScreenTitle, SectionCard } from '../components'; import { useAppColors, type AppColors } from '../theme'; +// A fixed key/value pair standing in for a real login token: this screen only +// needs to prove keychain-backed state survives `clear-app-state` but not +// `reset-keychain`, not to model an actual auth flow. +const KEYCHAIN_AUTH_KEY = 'automation-keychain-auth-token'; +const KEYCHAIN_AUTH_VALUE = 'demo-auth-token'; + type PushBroadcastLabModule = { lastPushBroadcast(): string; }; @@ -45,6 +52,7 @@ export function AutomationLabScreen(props: { const [microphonePermission, setMicrophonePermission] = useState('checking'); const [lastPushBroadcast, setLastPushBroadcast] = useState('none'); const [sheetVisible, setSheetVisible] = useState(false); + const [keychainAuthStatus, setKeychainAuthStatus] = useState('checking'); const permissionReadGeneration = useRef(0); const windowMode = dimensions.width > dimensions.height ? 'landscape' : 'portrait'; @@ -125,6 +133,26 @@ export function AutomationLabScreen(props: { setLastPushBroadcast(pushBroadcastLab?.lastPushBroadcast() ?? 'unavailable'); } + useEffect(() => { + let mounted = true; + void SecureStore.getItemAsync(KEYCHAIN_AUTH_KEY) + .then((value) => { + if (mounted) + setKeychainAuthStatus(value === KEYCHAIN_AUTH_VALUE ? 'signed-in' : 'signed-out'); + }) + .catch(() => { + if (mounted) setKeychainAuthStatus('error'); + }); + return () => { + mounted = false; + }; + }, []); + + async function signInWithKeychain() { + await SecureStore.setItemAsync(KEYCHAIN_AUTH_KEY, KEYCHAIN_AUTH_VALUE); + setKeychainAuthStatus('signed-in'); + } + return ( + + void signInWithKeychain()} + testID="automation-keychain-signin" + /> + + + { + const actual = await importOriginal(); + return { ...actual, runCmd: vi.fn(actual.runCmd) }; +}); +vi.mock('@agent-device/host-kit/retry', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, retryWithPolicy: vi.fn(actual.retryWithPolicy) }; +}); +vi.mock('../simulator.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ensureBootedSimulator: vi.fn(actual.ensureBootedSimulator) }; +}); + +const execActual = await vi.importActual( + '@agent-device/host-kit/command', +); +const retryActual = await vi.importActual( + '@agent-device/host-kit/retry', +); +const simulatorActual = await vi.importActual('../simulator.ts'); + +import { setIosSetting } from '../app-settings.ts'; +import { withMockedMacOsHelper } from './macos-helper-test-utils.ts'; +import { ensureBootedSimulator } from '../simulator.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { runCmd } from '@agent-device/host-kit/command'; +import { retryWithPolicy } from '@agent-device/host-kit/retry'; +import { assertRejectsAppError } from '../../__tests__/app-error.ts'; +import { withFakeAppleTool, type FakeAppleToolResponse } from '../../__tests__/fake-apple-tool.ts'; +import { IOS_TEST_SIMULATOR, MACOS_TEST_DEVICE } from './apple-core-stub-helpers.ts'; + +const mockRunCmd = vi.mocked(runCmd); +const mockRetryWithPolicy = vi.mocked(retryWithPolicy); +const mockEnsureBootedSimulator = vi.mocked(ensureBootedSimulator); + +beforeEach(() => { + vi.resetAllMocks(); + mockRunCmd.mockImplementation(execActual.runCmd); + mockRetryWithPolicy.mockImplementation(retryActual.retryWithPolicy); + mockEnsureBootedSimulator.mockImplementation(simulatorActual.ensureBootedSimulator); +}); + +// The fake tool provider installs through the production withAppleToolProvider +// scope, so `calls` records the flat invocations the PATH-stub scripts saw. + +const BOOTED_SIM_LIST_JSON = JSON.stringify({ + devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [{ udid: 'sim-1', state: 'Booted' }] }, +}); + +function isSimctlListDevices(args: string[]): boolean { + return ( + args[0] === 'simctl' && args.includes('list') && args.includes('devices') && args.includes('-j') + ); +} + +function unexpectedArgs(args: string[]): FakeAppleToolResponse { + return { stderr: `unexpected xcrun args: ${args.join(' ')}`, exitCode: 1 }; +} + +test('setIosSetting faceid match uses simctl biometric match', async () => { + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (args.join(' ') === 'simctl biometric sim-1 match face') return ''; + return unexpectedArgs(args); + }, + async ({ calls }) => { + await setIosSetting(IOS_TEST_SIMULATOR, 'faceid', 'match'); + const flat = calls.map((args) => args.join(' ')); + assert.equal(flat.includes('simctl biometric sim-1 match face'), true, flat.join('; ')); + }, + ); +}); + +test('setIosSetting faceid retries alternate biometric argument order', async () => { + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (args.join(' ') === 'simctl biometric sim-1 match face') return { exitCode: 2 }; + if (args.join(' ') === 'simctl biometric match sim-1 face') return ''; + return unexpectedArgs(args); + }, + async ({ calls }) => { + await setIosSetting(IOS_TEST_SIMULATOR, 'faceid', 'match'); + const flat = calls.map((args) => args.join(' ')); + assert.equal(flat.includes('simctl biometric sim-1 match face'), true, flat.join('; ')); + assert.equal(flat.includes('simctl biometric match sim-1 face'), true, flat.join('; ')); + }, + ); +}); + +test('setIosSetting touchid match uses simctl biometric match finger', async () => { + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (args.join(' ') === 'simctl biometric sim-1 match finger') return ''; + return unexpectedArgs(args); + }, + async ({ calls }) => { + await setIosSetting(IOS_TEST_SIMULATOR, 'touchid', 'match'); + const flat = calls.map((args) => args.join(' ')); + assert.equal(flat.includes('simctl biometric sim-1 match finger'), true, flat.join('; ')); + }, + ); +}); + +test('setIosSetting touchid retries touch modality when finger fails', async () => { + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (args.join(' ') === 'simctl biometric sim-1 match finger') return { exitCode: 2 }; + if (args.join(' ') === 'simctl biometric match sim-1 finger') return { exitCode: 2 }; + if (args.join(' ') === 'simctl biometric sim-1 match touch') return ''; + return unexpectedArgs(args); + }, + async ({ calls }) => { + await setIosSetting(IOS_TEST_SIMULATOR, 'touchid', 'match'); + const flat = calls.map((args) => args.join(' ')); + assert.equal(flat.includes('simctl biometric sim-1 match finger'), true, flat.join('; ')); + assert.equal(flat.includes('simctl biometric match sim-1 finger'), true, flat.join('; ')); + assert.equal(flat.includes('simctl biometric sim-1 match touch'), true, flat.join('; ')); + }, + ); +}); + +test('setIosSetting touchid reports unsupported when simctl biometric is unavailable', async () => { + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + return { stderr: 'unknown subcommand biometric', exitCode: 1 }; + }, + async () => { + await assertRejectsAppError(() => setIosSetting(IOS_TEST_SIMULATOR, 'touchid', 'match'), { + code: 'UNSUPPORTED_OPERATION', + message: /Touch ID simulation is not supported/, + }); + }, + ); +}); + +test('setIosSetting touchid keeps COMMAND_FAILED for operational failures', async () => { + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + return { stderr: 'Failed to boot simulator service', exitCode: 1 }; + }, + async () => { + await assertRejectsAppError(() => setIosSetting(IOS_TEST_SIMULATOR, 'touchid', 'match'), { + code: 'COMMAND_FAILED', + message: /Failed to simulate touchid/, + }); + }, + ); +}); + +test('setIosSetting appearance toggle queries current osascript appearance on macOS', async () => { + await withFakeAppleTool( + (args) => { + if (args[0] !== 'osascript' || args[1] !== '-e') return unexpectedArgs(args); + const script = args[2] ?? ''; + if (script.includes('get dark mode')) return 'true'; + if (script.includes('set dark mode to false')) return ''; + return unexpectedArgs(args); + }, + async ({ calls }) => { + await setIosSetting(MACOS_TEST_DEVICE, 'appearance', 'toggle'); + const flat = calls.map((args) => args.join(' ')); + assert.equal( + flat.some((line) => line.includes('get dark mode')), + true, + flat.join('; '), + ); + assert.equal( + flat.some((line) => line.includes('set dark mode to false')), + true, + flat.join('; '), + ); + }, + ); +}); + +test('setIosSetting permission grant accessibility uses macOS helper', async () => { + await withMockedMacOsHelper( + [ + '#!/bin/sh', + String.raw`printf "%s\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"`, + "cat <<'JSON'", + '{"ok":true,"data":{"target":"accessibility","action":"grant","granted":true,"requested":true,"openedSettings":false}}', + 'JSON', + '', + ].join('\n'), + async ({ tmpDir }) => { + const argsLogPath = path.join(tmpDir, 'args.log'); + const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; + process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; + + try { + const result = await setIosSetting(MACOS_TEST_DEVICE, 'permission', 'grant', undefined, { + permissionTarget: 'accessibility', + }); + const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.equal(logged, 'permission\ngrant\naccessibility\n'); + assert.deepEqual(result, { + action: 'grant', + granted: true, + openedSettings: false, + requested: true, + target: 'accessibility', + }); + } finally { + if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; + else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile; + } + }, + { tempPrefix: 'agent-device-macos-permission-grant-test-' }, + ); +}); + +test('setIosSetting rejects unsupported macOS permission deny action', async () => { + await assertRejectsAppError( + () => + setIosSetting(MACOS_TEST_DEVICE, 'permission', 'deny', undefined, { + permissionTarget: 'accessibility', + }), + { code: 'INVALID_ARGS', message: /Unsupported macOS setting: permission/i }, + ); +}); + +test('setIosSetting rejects unsupported macOS wifi setting with explicit subset guidance', async () => { + await assert.rejects( + () => setIosSetting(MACOS_TEST_DEVICE, 'wifi', 'on'), + (error: unknown) => { + assert.equal(error instanceof AppError, true); + assert.equal((error as AppError).code, 'INVALID_ARGS'); + assert.match((error as AppError).message, /Unsupported macOS setting: wifi/i); + assert.match( + (error as AppError).message, + /wifi\|airplane\|location\|animations remain unsupported on macOS/i, + ); + return true; + }, + ); +}); + +test('setIosSetting location set sends simulator latitude and longitude', async () => { + mockEnsureBootedSimulator.mockResolvedValue(undefined); + + await withFakeAppleTool( + () => '', + async ({ calls }) => { + 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 appearance toggle flips current simulator appearance', async () => { + 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; + // simctl privacy help falls through to the fake's canned service listing. + if (args[0] === 'simctl' && args[1] === 'privacy' && args[2] === 'help') return undefined; + 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 clear-app-state wipes iOS simulator app data container', async () => { + const containerPath = await mkdtempForTest('agent-device-ios-clear-app-state-container-'); + await fs.mkdir(path.join(containerPath, 'Documents'), { recursive: true }); + await fs.writeFile(path.join(containerPath, 'Documents', 'db.sqlite'), 'db'); + await fs.writeFile(path.join(containerPath, 'Library.plist'), 'prefs'); + + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (args.join(' ') === 'simctl terminate sim-1 com.example.app') return ''; + if (args.join(' ') === 'simctl get_app_container sim-1 com.example.app data') { + return `${containerPath}\n`; + } + return unexpectedArgs(args); + }, + async ({ calls }) => { + const result = await setIosSetting( + IOS_TEST_SIMULATOR, + 'clear-app-state', + 'clear', + 'com.example.app', + ); + assert.equal(result?.cleared, true); + assert.equal(result?.bundleId, 'com.example.app'); + assert.deepEqual(await fs.readdir(containerPath), []); + + const flat = calls.map((args) => args.join(' ')); + assert.equal(flat.includes('simctl terminate sim-1 com.example.app'), true, flat.join('; ')); + assert.equal( + flat.includes('simctl get_app_container sim-1 com.example.app data'), + true, + flat.join('; '), + ); + }, + ); +}); + +test('setIosSetting reset-keychain resets the whole simulator keychain', async () => { + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (args.join(' ') === 'simctl keychain sim-1 reset') return ''; + return unexpectedArgs(args); + }, + async ({ calls }) => { + const result = await setIosSetting(IOS_TEST_SIMULATOR, 'reset-keychain', 'clear'); + assert.equal(result?.cleared, true); + assert.equal(result?.scope, 'simulator'); + + const flat = calls.map((args) => args.join(' ')); + assert.equal(flat.includes('simctl keychain sim-1 reset'), true, flat.join('; ')); + }, + ); +}); + +test('setIosSetting reset-keychain rejects unsupported state', async () => { + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + return unexpectedArgs(args); + }, + async () => { + await assertRejectsAppError( + () => setIosSetting(IOS_TEST_SIMULATOR, 'reset-keychain', 'nope'), + { + code: 'INVALID_ARGS', + message: /reset-keychain only supports clear/, + }, + ); + }, + ); +}); + +test('setIosSetting permission grant photos limited maps to photos-add', async () => { + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (args[0] === 'simctl' && args[1] === 'privacy' && args[2] === 'help') return undefined; + 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 falls back to reset all when direct reset is blocked', async () => { + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (args[0] === 'simctl' && args[1] === 'privacy' && args[2] === 'help') return undefined; + if (args.join(' ') === 'simctl privacy sim-1 reset notifications com.example.app') { + return { stderr: 'Failed to reset access\nOperation not permitted', exitCode: 1 }; + } + if (args.join(' ') === 'simctl privacy sim-1 reset all com.example.app') return ''; + return unexpectedArgs(args); + }, + async ({ calls }) => { + await setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'reset', 'com.example.app', { + permissionTarget: 'notifications', + }); + 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.includes('simctl privacy sim-1 reset all com.example.app'), + true, + flat.join('; '), + ); + }, + ); +}); + +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[0] === 'simctl' && args[1] === 'privacy' && args[2] === 'help') return undefined; + if (args.join(' ') === 'simctl privacy sim-1 revoke notifications com.example.app') { + return { stderr: 'Failed to revoke access\nOperation not permitted', exitCode: 1 }; + } + return unexpectedArgs(args); + }, + async ({ calls }) => { + await assertRejectsAppError( + () => + setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'deny', 'com.example.app', { + permissionTarget: 'notifications', + }), + { + code: 'UNSUPPORTED_OPERATION', + 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 rejects service missing from simctl privacy help', async () => { + // A distinct simulator set path busts the module-level privacy-services + // cache, whose key is `PATH + set path` — the PATH half no longer varies + // now that no PATH stubbing happens, so the set path must. + const device: DeviceInfo = { ...IOS_TEST_SIMULATOR, simulatorSetPath: '/fake/privacy-help-set' }; + const CUSTOM_PRIVACY_HELP = `Usage: simctl privacy [] + + service + The service: + camera - Allow access to camera. + microphone - Allow access to audio input.`; + + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (args[0] === 'simctl' && args.includes('privacy') && args.includes('help')) { + return CUSTOM_PRIVACY_HELP; + } + return unexpectedArgs(args); + }, + async ({ calls }) => { + await assertRejectsAppError( + () => + setIosSetting(device, 'permission', 'grant', 'com.example.app', { + permissionTarget: 'calendar', + }), + { code: 'UNSUPPORTED_OPERATION', message: /does not support service "calendar"/i }, + ); + const flat = calls.map((args) => args.join(' ')); + assert.equal( + flat.some((line) => line.includes('privacy help')), + true, + flat.join('; '), + ); + assert.equal( + flat.some((line) => line.includes('grant calendar')), + false, + flat.join('; '), + ); + }, + ); +}); diff --git a/packages/platform-apple/src/core/__tests__/apps.test.ts b/packages/platform-apple/src/core/__tests__/apps.test.ts index 30dda18f7..1602dd0c5 100644 --- a/packages/platform-apple/src/core/__tests__/apps.test.ts +++ b/packages/platform-apple/src/core/__tests__/apps.test.ts @@ -29,13 +29,11 @@ import { closeIosApp, openIosApp } from '../app-launch.ts'; import { pushIosNotification, readIosClipboardText } from '../app-device-io.ts'; import { resolveIosApp, resolveIosSimulatorDeepLinkBundleId } from '../app-resolution.ts'; import { screenshotIos } from '../screenshot.ts'; -import { setIosSetting } from '../app-settings.ts'; import { withMockedMacOsHelper } from './macos-helper-test-utils.ts'; import { quitMacOsApp, resolveMacOsHelperPackageRootFrom } from '../../os/macos/helper.ts'; import { ensureBootedSimulator } from '../simulator.ts'; import { IOS_SIMULATOR_TERMINATE_TIMEOUT_MS } from '../config.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; import { runCmd } from '@agent-device/host-kit/command'; import { retryWithPolicy } from '@agent-device/host-kit/retry'; import { PNG } from '@agent-device/capture-kit/png'; @@ -705,442 +703,3 @@ test('resolveIosSimulatorDeepLinkBundleId maps custom URL scheme to installed us }, ); }); - -test('setIosSetting faceid match uses simctl biometric match', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args.join(' ') === 'simctl biometric sim-1 match face') return ''; - return unexpectedArgs(args); - }, - async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'faceid', 'match'); - const flat = calls.map((args) => args.join(' ')); - assert.equal(flat.includes('simctl biometric sim-1 match face'), true, flat.join('; ')); - }, - ); -}); - -test('setIosSetting faceid retries alternate biometric argument order', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args.join(' ') === 'simctl biometric sim-1 match face') return { exitCode: 2 }; - if (args.join(' ') === 'simctl biometric match sim-1 face') return ''; - return unexpectedArgs(args); - }, - async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'faceid', 'match'); - const flat = calls.map((args) => args.join(' ')); - assert.equal(flat.includes('simctl biometric sim-1 match face'), true, flat.join('; ')); - assert.equal(flat.includes('simctl biometric match sim-1 face'), true, flat.join('; ')); - }, - ); -}); - -test('setIosSetting touchid match uses simctl biometric match finger', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args.join(' ') === 'simctl biometric sim-1 match finger') return ''; - return unexpectedArgs(args); - }, - async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'touchid', 'match'); - const flat = calls.map((args) => args.join(' ')); - assert.equal(flat.includes('simctl biometric sim-1 match finger'), true, flat.join('; ')); - }, - ); -}); - -test('setIosSetting touchid retries touch modality when finger fails', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args.join(' ') === 'simctl biometric sim-1 match finger') return { exitCode: 2 }; - if (args.join(' ') === 'simctl biometric match sim-1 finger') return { exitCode: 2 }; - if (args.join(' ') === 'simctl biometric sim-1 match touch') return ''; - return unexpectedArgs(args); - }, - async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'touchid', 'match'); - const flat = calls.map((args) => args.join(' ')); - assert.equal(flat.includes('simctl biometric sim-1 match finger'), true, flat.join('; ')); - assert.equal(flat.includes('simctl biometric match sim-1 finger'), true, flat.join('; ')); - assert.equal(flat.includes('simctl biometric sim-1 match touch'), true, flat.join('; ')); - }, - ); -}); - -test('setIosSetting touchid reports unsupported when simctl biometric is unavailable', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - return { stderr: 'unknown subcommand biometric', exitCode: 1 }; - }, - async () => { - await assertRejectsAppError(() => setIosSetting(IOS_TEST_SIMULATOR, 'touchid', 'match'), { - code: 'UNSUPPORTED_OPERATION', - message: /Touch ID simulation is not supported/, - }); - }, - ); -}); - -test('setIosSetting touchid keeps COMMAND_FAILED for operational failures', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - return { stderr: 'Failed to boot simulator service', exitCode: 1 }; - }, - async () => { - await assertRejectsAppError(() => setIosSetting(IOS_TEST_SIMULATOR, 'touchid', 'match'), { - code: 'COMMAND_FAILED', - message: /Failed to simulate touchid/, - }); - }, - ); -}); - -test('setIosSetting appearance toggle queries current osascript appearance on macOS', async () => { - await withFakeAppleTool( - (args) => { - if (args[0] !== 'osascript' || args[1] !== '-e') return unexpectedArgs(args); - const script = args[2] ?? ''; - if (script.includes('get dark mode')) return 'true'; - if (script.includes('set dark mode to false')) return ''; - return unexpectedArgs(args); - }, - async ({ calls }) => { - await setIosSetting(MACOS_TEST_DEVICE, 'appearance', 'toggle'); - const flat = calls.map((args) => args.join(' ')); - assert.equal( - flat.some((line) => line.includes('get dark mode')), - true, - flat.join('; '), - ); - assert.equal( - flat.some((line) => line.includes('set dark mode to false')), - true, - flat.join('; '), - ); - }, - ); -}); - -test('setIosSetting permission grant accessibility uses macOS helper', async () => { - await withMockedMacOsHelper( - [ - '#!/bin/sh', - String.raw`printf "%s\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"`, - "cat <<'JSON'", - '{"ok":true,"data":{"target":"accessibility","action":"grant","granted":true,"requested":true,"openedSettings":false}}', - 'JSON', - '', - ].join('\n'), - async ({ tmpDir }) => { - const argsLogPath = path.join(tmpDir, 'args.log'); - const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; - process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; - - try { - const result = await setIosSetting(MACOS_TEST_DEVICE, 'permission', 'grant', undefined, { - permissionTarget: 'accessibility', - }); - const logged = await fs.readFile(argsLogPath, 'utf8'); - assert.equal(logged, 'permission\ngrant\naccessibility\n'); - assert.deepEqual(result, { - action: 'grant', - granted: true, - openedSettings: false, - requested: true, - target: 'accessibility', - }); - } finally { - if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; - else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile; - } - }, - { tempPrefix: 'agent-device-macos-permission-grant-test-' }, - ); -}); - -test('setIosSetting rejects unsupported macOS permission deny action', async () => { - await assertRejectsAppError( - () => - setIosSetting(MACOS_TEST_DEVICE, 'permission', 'deny', undefined, { - permissionTarget: 'accessibility', - }), - { code: 'INVALID_ARGS', message: /Unsupported macOS setting: permission/i }, - ); -}); - -test('setIosSetting rejects unsupported macOS wifi setting with explicit subset guidance', async () => { - await assert.rejects( - () => setIosSetting(MACOS_TEST_DEVICE, 'wifi', 'on'), - (error: unknown) => { - assert.equal(error instanceof AppError, true); - assert.equal((error as AppError).code, 'INVALID_ARGS'); - assert.match((error as AppError).message, /Unsupported macOS setting: wifi/i); - assert.match( - (error as AppError).message, - /wifi\|airplane\|location\|animations remain unsupported on macOS/i, - ); - return true; - }, - ); -}); - -test('setIosSetting location set sends simulator latitude and longitude', async () => { - mockEnsureBootedSimulator.mockResolvedValue(undefined); - - await withFakeAppleTool( - () => '', - async ({ calls }) => { - 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 appearance toggle flips current simulator appearance', async () => { - 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; - // simctl privacy help falls through to the fake's canned service listing. - if (args[0] === 'simctl' && args[1] === 'privacy' && args[2] === 'help') return undefined; - 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 clear-app-state wipes iOS simulator app data container', async () => { - const containerPath = await mkdtempForTest('agent-device-ios-clear-app-state-container-'); - await fs.mkdir(path.join(containerPath, 'Documents'), { recursive: true }); - await fs.writeFile(path.join(containerPath, 'Documents', 'db.sqlite'), 'db'); - await fs.writeFile(path.join(containerPath, 'Library.plist'), 'prefs'); - - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args.join(' ') === 'simctl terminate sim-1 com.example.app') return ''; - if (args.join(' ') === 'simctl get_app_container sim-1 com.example.app data') { - return `${containerPath}\n`; - } - return unexpectedArgs(args); - }, - async ({ calls }) => { - const result = await setIosSetting( - IOS_TEST_SIMULATOR, - 'clear-app-state', - 'clear', - 'com.example.app', - ); - assert.equal(result?.cleared, true); - assert.equal(result?.bundleId, 'com.example.app'); - assert.deepEqual(await fs.readdir(containerPath), []); - - const flat = calls.map((args) => args.join(' ')); - assert.equal(flat.includes('simctl terminate sim-1 com.example.app'), true, flat.join('; ')); - assert.equal( - flat.includes('simctl get_app_container sim-1 com.example.app data'), - true, - flat.join('; '), - ); - }, - ); -}); - -test('setIosSetting permission grant photos limited maps to photos-add', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args[0] === 'simctl' && args[1] === 'privacy' && args[2] === 'help') return undefined; - 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 falls back to reset all when direct reset is blocked', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args[0] === 'simctl' && args[1] === 'privacy' && args[2] === 'help') return undefined; - if (args.join(' ') === 'simctl privacy sim-1 reset notifications com.example.app') { - return { stderr: 'Failed to reset access\nOperation not permitted', exitCode: 1 }; - } - if (args.join(' ') === 'simctl privacy sim-1 reset all com.example.app') return ''; - return unexpectedArgs(args); - }, - async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'reset', 'com.example.app', { - permissionTarget: 'notifications', - }); - 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.includes('simctl privacy sim-1 reset all com.example.app'), - true, - flat.join('; '), - ); - }, - ); -}); - -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[0] === 'simctl' && args[1] === 'privacy' && args[2] === 'help') return undefined; - if (args.join(' ') === 'simctl privacy sim-1 revoke notifications com.example.app') { - return { stderr: 'Failed to revoke access\nOperation not permitted', exitCode: 1 }; - } - return unexpectedArgs(args); - }, - async ({ calls }) => { - await assertRejectsAppError( - () => - setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'deny', 'com.example.app', { - permissionTarget: 'notifications', - }), - { - code: 'UNSUPPORTED_OPERATION', - 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 rejects service missing from simctl privacy help', async () => { - // A distinct simulator set path busts the module-level privacy-services - // cache, whose key is `PATH + set path` — the PATH half no longer varies - // now that no PATH stubbing happens, so the set path must. - const device: DeviceInfo = { ...IOS_TEST_SIMULATOR, simulatorSetPath: '/fake/privacy-help-set' }; - const CUSTOM_PRIVACY_HELP = `Usage: simctl privacy [] - - service - The service: - camera - Allow access to camera. - microphone - Allow access to audio input.`; - - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args[0] === 'simctl' && args.includes('privacy') && args.includes('help')) { - return CUSTOM_PRIVACY_HELP; - } - return unexpectedArgs(args); - }, - async ({ calls }) => { - await assertRejectsAppError( - () => - setIosSetting(device, 'permission', 'grant', 'com.example.app', { - permissionTarget: 'calendar', - }), - { code: 'UNSUPPORTED_OPERATION', message: /does not support service "calendar"/i }, - ); - const flat = calls.map((args) => args.join(' ')); - assert.equal( - flat.some((line) => line.includes('privacy help')), - true, - flat.join('; '), - ); - assert.equal( - flat.some((line) => line.includes('grant calendar')), - false, - flat.join('; '), - ); - }, - ); -}); diff --git a/packages/platform-apple/src/core/app-settings.ts b/packages/platform-apple/src/core/app-settings.ts index 56acf8a4b..7ff4666b4 100644 --- a/packages/platform-apple/src/core/app-settings.ts +++ b/packages/platform-apple/src/core/app-settings.ts @@ -75,6 +75,18 @@ export async function setIosSetting( const result = await clearIosSimulatorAppState(device, appBundleId); return { bundleId: result.bundleId, containerPath: result.containerPath, cleared: true }; } + case 'reset-keychain': { + if (state.toLowerCase() !== 'clear') { + throw new AppError('INVALID_ARGS', 'settings reset-keychain only supports clear.'); + } + await runSimctl(device, ['keychain', device.id, 'reset']); + return { + scope: 'simulator', + cleared: true, + message: + 'Reset the whole iOS simulator keychain. This clears keychain-backed credentials for every installed app, not just the app under test.', + }; + } case 'wifi': { const enabled = parseSettingState(state); const mode = enabled ? 'active' : 'failed'; diff --git a/src/__tests__/cli-grammar.test.ts b/src/__tests__/cli-grammar.test.ts index fdc71109b..e9a2a2c75 100644 --- a/src/__tests__/cli-grammar.test.ts +++ b/src/__tests__/cli-grammar.test.ts @@ -197,4 +197,25 @@ test('settings grammar owns positional parsing for CLI commands', () => { assert.equal(clearAppState.setting, 'clear-app-state'); assert.equal(clearAppState.state, 'clear'); assert.equal(clearAppState.app, 'com.example.app'); + + const resetKeychain = readInputFromCli('settings', ['reset-keychain', 'clear'], { + ...BASE_FLAGS, + platform: 'ios', + }); + assert.equal(resetKeychain.setting, 'reset-keychain'); + assert.equal(resetKeychain.state, 'clear'); +}); + +test('settings reset-keychain rejects an extra app argument instead of dropping it', () => { + assert.throws( + () => + readInputFromCli('settings', ['reset-keychain', 'clear', 'com.example.app'], { + ...BASE_FLAGS, + platform: 'ios', + }), + (err: any) => { + assert.equal(err.code, 'INVALID_ARGS'); + return true; + }, + ); }); diff --git a/src/cli-schema/cli-help-command-usage.test.ts b/src/cli-schema/cli-help-command-usage.test.ts index 1f9ed039c..38ec4a660 100644 --- a/src/cli-schema/cli-help-command-usage.test.ts +++ b/src/cli-schema/cli-help-command-usage.test.ts @@ -422,6 +422,7 @@ test('settings usage documents canonical faceid states', async () => { if (help === null) throw new Error('Expected command help text'); assert.match(help, /location set /); assert.match(help, /clear-app-state \[app-id\]/); + assert.match(help, /reset-keychain clear/); assert.match(help, /light\|dark\|toggle/); assert.match(help, /match\|nonmatch\|enroll\|unenroll/); assert.match( diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index 60927ea0e..81efee4bd 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -54,7 +54,7 @@ export const settingsCommandFacet = defineCommandFacet({ text: { summary: 'Change OS settings and app permissions', cliDetail: - 'macOS supports only settings appearance and settings permission ; wifi|airplane|location|animations remain unsupported on macOS. Mobile permission actions use the active session app. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one. Android settings airplane on|off is applied by the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing device state.', + 'macOS supports only settings appearance and settings permission ; wifi|airplane|location|animations remain unsupported on macOS. Mobile permission actions use the active session app. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one. Android settings airplane on|off is applied by the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing device state. settings reset-keychain clear is iOS-simulator-only and resets the whole simulator keychain, not just the selected app: simctl exposes no per-app keychain reset, so every app on that simulator loses its keychain-backed credentials (e.g. Firebase auth). clear-app-state does not touch the keychain, so a full fresh-install reset needs both; relaunch the app afterward to observe the signed-out state.', }, metadata: settingsCommandMetadata, run: (client, input) => client.settings.update(input as SettingsUpdateOptions), @@ -107,6 +107,9 @@ function readSettingsOptionsFromPositionals( const app = state === 'clear' ? positionals[2] : state; return { ...base, setting, state: 'clear', app }; } + if (setting === 'reset-keychain' && state === 'clear' && positionals.length === 2) { + return { ...base, setting, state }; + } throw new AppError('INVALID_ARGS', 'Invalid settings arguments.'); } diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 522da36e5..d9108e17c 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -20,7 +20,6 @@ import type { CaptureSnapshotResult } from '@agent-device/contracts/client'; import { buildNodes } from '../../../__tests__/test-utils/snapshot-builders.ts'; import { fixtureScreenshotCaptures, - fixtureSettingsMutations, resetSnapshotRuntimeFixture, snapshotRuntimeFixture, } from '../../__tests__/snapshot-runtime-fixture.ts'; @@ -34,7 +33,6 @@ import { inboxRow, iosSimulatorDevice, locationRequiredCapture, - macOsDevice, makeAndroidFreshnessSession, makeProviderRuntimeOwning, makeSession, @@ -1441,125 +1439,6 @@ test('wait timeout without readable capture does not inspect the current surface expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); -test('settings rejects unsupported iOS physical devices', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'My iPhone', - kind: 'device', - booted: true, - }), - ); - - const response = await handleSnapshotCommands({ - req: snapshotRequest(sessionName, 'settings', { positionals: ['wifi', 'on'] }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); - expect(response.error.message).toMatch(/settings is not supported/i); - } -}); - -test('settings clear-app-state dispatches explicit app id without an active app session', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-clear-state'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); - - const response = await handleSnapshotCommands({ - req: snapshotRequest(sessionName, 'settings', { - positionals: ['clear-app-state', 'org.reactnavigation.playground'], - }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - - expect(response?.ok).toBe(true); - expect(fixtureSettingsMutations.at(-1)).toMatchObject({ - setting: 'clear-app-state', - state: 'clear', - appBundleId: 'org.reactnavigation.playground', - }); -}); - -test('settings clear-app-state rejects missing app id when no app session is bound', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-clear-state-missing-app'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); - - const response = await handleSnapshotCommands({ - req: snapshotRequest(sessionName, 'settings', { positionals: ['clear-app-state'] }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - - expect(response?.ok).toBe(false); - if (response?.ok === false) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/requires an app id/i); - } - expect(fixtureSettingsMutations).toHaveLength(0); -}); - -test('settings usage hint documents canonical faceid states', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSnapshotCommands({ - req: snapshotRequest('default', 'settings'), - sessionName: 'default', - logPath: '/tmp/daemon.log', - sessionStore, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/appearance /); - expect(response.error.message).toMatch(/match\|nonmatch\|enroll\|unenroll/); - expect(response.error.message).toMatch(/grant\|deny\|reset/); - expect(response.error.message).not.toMatch(/validate\|unvalidate/); - } -}); - -test('settings on macOS rejects wifi before dispatch with explicit subset guidance', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'macos-settings-wifi'; - sessionStore.set(sessionName, makeSession(sessionName, macOsDevice)); - - const response = await handleSnapshotCommands({ - req: snapshotRequest(sessionName, 'settings', { positionals: ['wifi', 'on'] }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - expect(fixtureSettingsMutations).toHaveLength(0); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/Unsupported macOS setting: wifi/i); - expect(response.error.message).toMatch(/appearance /); - expect(response.error.message).toMatch( - /permission /, - ); - expect(response.error.message).toMatch( - /wifi\|airplane\|location\|animations remain unsupported on macOS/i, - ); - } -}); - test('diff rejects unsupported kind', async () => { const sessionStore = makeSessionStore(); const response = await handleSnapshotCommands({ diff --git a/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts new file mode 100644 index 000000000..c3aca6103 --- /dev/null +++ b/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts @@ -0,0 +1,228 @@ +import { test, expect, vi, afterEach, beforeEach } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { handleSnapshotCommands as handleProductionSnapshotCommands } from '../snapshot.ts'; +import { setActiveProviderDeviceRuntimes } from '../../../provider-device-runtime.ts'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; +import { + fixtureSettingsMutations, + resetSnapshotRuntimeFixture, + snapshotRuntimeFixture, +} from '../../__tests__/snapshot-runtime-fixture.ts'; +import { + iosSimulatorDevice, + macOsDevice, + makeSession, + makeSessionStore, + snapshotRequest, +} from './snapshot-handler.fixtures.ts'; + +vi.mock('../../snapshot-interactor-capture.ts', async () => { + const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); + return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; +}); +vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, runAppleRunnerCommand: vi.fn(async () => ({})) }; +}); + +// The real implementation shells out to simctl to probe for a hint-worthy +// unambiguous environment; that live-probe logic is covered by +// ios-app-session-hint.test.ts. Stubbed here so this suite stays hermetic and +// fast — defaults to "no enrichment", matching the current-behavior fallback. +vi.mock('../../ios-app-session-hint.ts', () => ({ + buildIosOpenCommandHint: vi.fn(async () => undefined), +})); + +import { runAppleRunnerCommand } from '@agent-device/platform-apple/runner/operations'; +import { buildIosOpenCommandHint } from '../../ios-app-session-hint.ts'; + +const mockRunnerCommand = vi.mocked(runAppleRunnerCommand); +const mockBuildIosOpenCommandHint = vi.mocked(buildIosOpenCommandHint); + +function handleSnapshotCommands( + params: Parameters[0], +): ReturnType { + const runtime = snapshotRuntimeFixture(params.req.meta?.requestId); + return handleProductionSnapshotCommands({ + ...params, + inspectFacts: params.inspectFacts ?? runtime.inspectFacts, + bindDevice: params.bindDevice ?? runtime.bindDevice, + platformResourceCleanup: params.platformResourceCleanup ?? platformResourceCleanup, + }); +} + +afterEach(() => { + setActiveProviderDeviceRuntimes([]); +}); + +beforeEach(() => { + resetSnapshotRuntimeFixture(); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); + mockRunnerCommand.mockReset(); + mockRunnerCommand.mockResolvedValue({}); + mockBuildIosOpenCommandHint.mockReset(); + mockBuildIosOpenCommandHint.mockResolvedValue(undefined); +}); + +test('settings rejects unsupported iOS physical devices', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'My iPhone', + kind: 'device', + booted: true, + }), + ); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { positionals: ['wifi', 'on'] }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); + expect(response.error.message).toMatch(/settings is not supported/i); + } +}); + +test('settings clear-app-state dispatches explicit app id without an active app session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-clear-state'; + sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { + positionals: ['clear-app-state', 'org.reactnavigation.playground'], + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + expect(fixtureSettingsMutations.at(-1)).toMatchObject({ + setting: 'clear-app-state', + state: 'clear', + appBundleId: 'org.reactnavigation.playground', + }); +}); + +test('settings clear-app-state rejects missing app id when no app session is bound', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-clear-state-missing-app'; + sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { positionals: ['clear-app-state'] }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(false); + if (response?.ok === false) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/requires an app id/i); + } + expect(fixtureSettingsMutations).toHaveLength(0); +}); + +test('settings reset-keychain dispatches without an app id or active app session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-reset-keychain'; + sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { + positionals: ['reset-keychain', 'clear'], + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + expect(fixtureSettingsMutations.at(-1)).toMatchObject({ + setting: 'reset-keychain', + state: 'clear', + }); +}); + +test('settings reset-keychain rejects an extra app argument instead of dropping it', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-reset-keychain-extra-arg'; + sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { + positionals: ['reset-keychain', 'clear', 'com.example.app'], + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(false); + if (response?.ok === false) { + expect(response.error.code).toBe('INVALID_ARGS'); + } + expect(fixtureSettingsMutations).toHaveLength(0); +}); + +test('settings usage hint documents canonical faceid states', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSnapshotCommands({ + req: snapshotRequest('default', 'settings'), + sessionName: 'default', + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/appearance /); + expect(response.error.message).toMatch(/match\|nonmatch\|enroll\|unenroll/); + expect(response.error.message).toMatch(/grant\|deny\|reset/); + expect(response.error.message).not.toMatch(/validate\|unvalidate/); + } +}); + +test('settings on macOS rejects wifi before dispatch with explicit subset guidance', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'macos-settings-wifi'; + sessionStore.set(sessionName, makeSession(sessionName, macOsDevice)); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { positionals: ['wifi', 'on'] }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + expect(fixtureSettingsMutations).toHaveLength(0); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/Unsupported macOS setting: wifi/i); + expect(response.error.message).toMatch(/appearance /); + expect(response.error.message).toMatch( + /permission /, + ); + expect(response.error.message).toMatch( + /wifi\|airplane\|location\|animations remain unsupported on macOS/i, + ); + } +}); diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index 0be848340..b084fd43e 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -64,7 +64,10 @@ export function parseSettingsArgs( !setting || !state || (setting === 'permission' && !permissionTarget) || - (setting === 'location' && state === 'set' && (!req.positionals?.[2] || !req.positionals?.[3])) + (setting === 'location' && + state === 'set' && + (!req.positionals?.[2] || !req.positionals?.[3])) || + (setting === 'reset-keychain' && req.positionals?.[2] !== undefined) ) { return errorResponse('INVALID_ARGS', SETTINGS_INVALID_ARGS_MESSAGE); } diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 1b6b1a352..8da1f63bc 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -696,6 +696,7 @@ agent-device settings fingerprint match agent-device settings fingerprint nonmatch agent-device settings clear-app-state agent-device settings clear-app-state com.example.app +agent-device settings reset-keychain clear agent-device settings permission grant camera agent-device settings permission deny microphone agent-device settings permission grant photos limited @@ -710,7 +711,8 @@ agent-device settings permission reset screen-recording --platform macos - Android `settings animations off|on` toggles the global `window_animation_scale`, `transition_animation_scale`, and `animator_duration_scale` values. Use it as an opt-in stabilizer for automation runs with heavy system or app animations, then restore with `settings animations on` when needed. - `settings appearance` maps to macOS appearance, iOS simulator appearance, and Android night mode. - `settings location set ` sets precise coordinates on iOS simulators and Android emulators. -- `settings clear-app-state [app-id]` clears the active session app data, or the provided app id. Android uses `pm clear`, which removes SharedPreferences, databases, files, and cache. iOS simulator removes the app data container contents. iOS physical devices and macOS are unsupported. +- `settings clear-app-state [app-id]` clears the active session app data, or the provided app id. Android uses `pm clear`, which removes SharedPreferences, databases, files, and cache. iOS simulator removes the app data container contents. iOS physical devices and macOS are unsupported. It does not touch the keychain, so keychain-backed credentials (e.g. Firebase auth) survive it. +- `settings reset-keychain clear` resets the iOS simulator's keychain (`xcrun simctl keychain reset`), removing keychain-backed credentials such as Firebase auth tokens that `clear-app-state` leaves behind. simctl has no per-app keychain reset, so this clears the keychain for every app installed on that simulator, not only the app under test — treat it as a whole-simulator, opt-in operation and pair it with `clear-app-state` for a full fresh-install reset. iOS physical devices, Android, and macOS are unsupported. - Face ID and Touch ID controls are iOS simulator-only. - Android `settings airplane on|off` is applied by the connectivity service (`cmd connectivity airplane-mode`, Android 11+), which drives the radios rather than only writing the `airplane_mode_on` setting. The response reports the `airplaneMode` that service holds after the change, and Android builds without that command fail without changing device state. Connectivity takes a moment to settle after the switch, so poll the app under test rather than asserting offline behavior immediately. - Fingerprint simulation is supported on Android targets where `cmd fingerprint` or `adb emu finger` is available.