diff --git a/README.md b/README.md index 68bb2d14..28b0e244 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,30 @@ const config = { export default config; ``` +## Device Permissions Fixture + +Harness now exposes a deterministic `device.permissions` API for test setup: + +```ts +import { device } from 'react-native-harness'; + +await device.permissions.grant('microphone'); +await device.permissions.revoke('microphone'); +await device.permissions.reset('microphone'); + +await device.permissions.grantAll(); +await device.permissions.denyAll(); +await device.permissions.resetAll(); +``` + +Platform behavior: + +- Android emulator and physical device: named permissions and bulk operations use host-side ADB commands. +- iOS simulator: named permissions and bulk operations use `xcrun simctl privacy` for supported simulator services. +- iOS physical device: named permission calls warn and no-op; bulk calls configure XCTest Agent prompt handling for future permission prompts. + +Existing `permissions: true` config continues to work and now maps to `grantAll()` semantics through the same host-side permission controller. + ## Documentation The documentation is available at [react-native-harness.dev](https://react-native-harness.dev). You can also use the following links to jump to specific topics: diff --git a/packages/bridge/src/client.ts b/packages/bridge/src/client.ts index a4d45e0a..21f3e19f 100644 --- a/packages/bridge/src/client.ts +++ b/packages/bridge/src/client.ts @@ -27,6 +27,12 @@ export type HarnessHandle = { reportReady: (device: DeviceDescriptor) => void; /** Forward a test or bundler event to the CLI. */ emitEvent: (event: BridgeEvents) => void; + applyDevicePermission: ( + command: Parameters[0], + ) => ReturnType; + revertDevicePermission: ( + mutationId: string, + ) => ReturnType; /** Send a screenshot to the CLI and receive a file reference for snapshot comparison. */ transferScreenshot: ( data: Uint8Array, @@ -96,6 +102,9 @@ export const connectToHarness = ( resolve({ reportReady: (device) => void rpc.reportReady(device), emitEvent: (event) => void rpc.emitEvent(event.type, event), + applyDevicePermission: (command) => rpc['device.permissions.apply'](command), + revertDevicePermission: (mutationId) => + rpc['device.permissions.revert'](mutationId), transferScreenshot: async (data, metadata) => { const transferId = generateTransferId(); ws.send(createBinaryFrame(transferId, data)); diff --git a/packages/bridge/src/server.ts b/packages/bridge/src/server.ts index a00ba253..ad7cf974 100644 --- a/packages/bridge/src/server.ts +++ b/packages/bridge/src/server.ts @@ -159,6 +159,27 @@ export const createHarnessBridge = async ( emitEvent: (_, data) => { emitter.emit('event', data); }, + 'device.permissions.apply': async (command) => { + const deviceState = context.getDeviceState(); + if (!deviceState) { + throw new Error( + `device.permissions is not supported by runner "${context.platform.name}".`, + ); + } + + const result = await deviceState.permissions.apply(command); + return { mutationId: result.mutation?.id, warning: result.warning }; + }, + 'device.permissions.revert': async (mutationId) => { + const deviceState = context.getDeviceState(); + if (!deviceState) { + throw new Error( + `device.permissions is not supported by runner "${context.platform.name}".`, + ); + } + + await deviceState.permissions.revert(mutationId); + }, 'device.screenshot.receive': (ref) => receiveScreenshot(binaryStore, ref), 'test.matchImageSnapshot': (screenshot, testPath, opts) => matchImageSnapshot(screenshot, testPath, opts, context.platform.name), @@ -201,7 +222,6 @@ export const createHarnessBridge = async ( functionName, args, ); - throw error; }, onTimeoutError: (fn, args) => { throw new DeviceNotRespondingError(fn, args); diff --git a/packages/bridge/src/shared.ts b/packages/bridge/src/shared.ts index d8196db8..55917a76 100644 --- a/packages/bridge/src/shared.ts +++ b/packages/bridge/src/shared.ts @@ -4,7 +4,11 @@ import type { } from './shared/test-runner.js'; import type { TestCollectorEvents } from './shared/test-collector.js'; import type { BundlerEvents } from './shared/bundler.js'; -import type { HarnessPlatform } from '@react-native-harness/platforms'; +import type { + DevicePermissionCommand, + DeviceStateController, + HarnessPlatform, +} from '@react-native-harness/platforms'; export const HARNESS_BRIDGE_PATH = '/__harness'; @@ -150,6 +154,10 @@ export type ScreenshotData = BinaryDataReference; export type BridgeServerFunctions = { reportReady: (device: DeviceDescriptor) => void; emitEvent: (event: BridgeEvents['type'], data: BridgeEvents) => void; + 'device.permissions.apply': ( + command: DevicePermissionCommand, + ) => Promise<{ mutationId?: string; warning?: string }>; + 'device.permissions.revert': (mutationId: string) => Promise; 'device.screenshot.receive': ( reference: BinaryDataReference, metadata: { width: number; height: number } @@ -163,5 +171,6 @@ export type BridgeServerFunctions = { }; export type HarnessContext = { + getDeviceState: () => DeviceStateController | undefined; platform: HarnessPlatform; }; diff --git a/packages/jest/src/__tests__/bridge.test.ts b/packages/jest/src/__tests__/bridge.test.ts index b929290a..848459ec 100644 --- a/packages/jest/src/__tests__/bridge.test.ts +++ b/packages/jest/src/__tests__/bridge.test.ts @@ -8,14 +8,18 @@ import type { HarnessBridge } from '@react-native-harness/bridge/server'; import { createHarnessBridge } from '@react-native-harness/bridge/server'; import { connectToHarness } from '@react-native-harness/bridge/client'; import type { HarnessContext } from '@react-native-harness/bridge'; +import type { DeviceStateController } from '@react-native-harness/platforms'; -const makeContext = (): HarnessContext => ({ +const makeContext = ( + deviceState?: DeviceStateController, +): HarnessContext => ({ platform: { name: 'ios', platformId: 'ios', runner: '/dev/null', config: {}, }, + getDeviceState: () => deviceState, }); // --------------------------------------------------------------------------- @@ -153,6 +157,64 @@ describe('bridge: createHarnessBridge + connectToHarness', () => { }); }); + describe('device permissions RPCs', () => { + it('applies permission mutations through the host-side controller', async () => { + const apply = vi.fn(async () => ({ + mutation: { + id: 'mutation-1', + revert: vi.fn(async () => undefined), + }, + })); + const revert = vi.fn(async () => undefined); + + bridge.dispose(); + bridge = await createHarnessBridge({ + port: 0, + context: makeContext({ + permissions: { + apply, + revert, + resetOutstanding: vi.fn(async () => undefined), + }, + }), + }); + bridgePort = (bridge.ws.address() as { port: number }).port; + + const handle = await connect(); + + await expect( + handle.applyDevicePermission({ + kind: 'permission', + permission: 'microphone', + decision: 'grant', + }), + ).resolves.toEqual({ mutationId: 'mutation-1' }); + + await handle.revertDevicePermission('mutation-1'); + + expect(apply).toHaveBeenCalledWith({ + kind: 'permission', + permission: 'microphone', + decision: 'grant', + }); + expect(revert).toHaveBeenCalledWith('mutation-1'); + handle.disconnect(); + }); + + it('returns a clear error when no device-state controller exists', async () => { + const handle = await connect(); + + await expect( + handle.applyDevicePermission({ + kind: 'permission-all', + decision: 'grant', + }), + ).rejects.toThrow('device.permissions is not supported by runner "ios".'); + + handle.disconnect(); + }); + }); + describe('bridge events', () => { it('emitEvent on app side fires the event listener on bridge', async () => { const onEvent = vi.fn(); diff --git a/packages/jest/src/__tests__/fixtures/mock-platform-runner.ts b/packages/jest/src/__tests__/fixtures/mock-platform-runner.ts new file mode 100644 index 00000000..52fd2698 --- /dev/null +++ b/packages/jest/src/__tests__/fixtures/mock-platform-runner.ts @@ -0,0 +1,23 @@ +import type { + HarnessPlatformInitOptions, + HarnessPlatformRunner, +} from '@react-native-harness/platforms'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; + +declare global { + var __HARNESS_TEST_PLATFORM_RUNNER__: + | HarnessPlatformRunner + | undefined; +} + +export default async function mockPlatformRunner( + _platformConfig: Record, + _harnessConfig: HarnessConfig, + _init: HarnessPlatformInitOptions, +): Promise { + if (!globalThis.__HARNESS_TEST_PLATFORM_RUNNER__) { + throw new Error('Missing __HARNESS_TEST_PLATFORM_RUNNER__ test fixture'); + } + + return globalThis.__HARNESS_TEST_PLATFORM_RUNNER__; +} diff --git a/packages/jest/src/__tests__/harness-session.test.ts b/packages/jest/src/__tests__/harness-session.test.ts new file mode 100644 index 00000000..27e84d08 --- /dev/null +++ b/packages/jest/src/__tests__/harness-session.test.ts @@ -0,0 +1,357 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config as JestConfig } from 'jest-runner'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { + AppMonitor, + DeviceStateController, + HarnessPlatformRunner, +} from '@react-native-harness/platforms'; +import { createHarnessSession } from '../harness-session.js'; + +const permissionPromptWatchdogPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + '..', + 'platform-ios', + 'xctest-agent', + 'HarnessXCTestAgentUITests', + 'PermissionPromptWatchdog.swift', +); + +const pluginManagerMocks = vi.hoisted(() => ({ + callHook: vi.fn(async () => undefined), +})); + +const getConfigMock = vi.hoisted(() => vi.fn()); +const resolveHarnessMetroPortMock = vi.hoisted(() => vi.fn()); +const getMetroInstanceMock = vi.hoisted(() => vi.fn()); +const createHarnessBridgeMock = vi.hoisted(() => vi.fn()); +const createCrashMonitorMock = vi.hoisted(() => vi.fn()); + +vi.mock('@react-native-harness/plugins', async () => { + const actual = await vi.importActual( + '@react-native-harness/plugins', + ); + + return { + ...actual, + createHarnessPluginManager: vi.fn(() => ({ + callHook: pluginManagerMocks.callHook, + })), + }; +}); + +vi.mock('@react-native-harness/config', async () => { + const actual = await vi.importActual( + '@react-native-harness/config', + ); + + return { + ...actual, + getConfig: getConfigMock, + }; +}); + +vi.mock('../metro-port.js', async () => { + const actual = await vi.importActual( + '../metro-port.js', + ); + + return { + ...actual, + resolveHarnessMetroPort: resolveHarnessMetroPortMock, + }; +}); + +vi.mock('@react-native-harness/bundler-metro', async () => { + const actual = await vi.importActual( + '@react-native-harness/bundler-metro', + ); + + return { + ...actual, + getMetroInstance: getMetroInstanceMock, + }; +}); + +vi.mock('@react-native-harness/bridge/server', async () => { + const actual = await vi.importActual( + '@react-native-harness/bridge/server', + ); + + return { + ...actual, + createHarnessBridge: createHarnessBridgeMock, + }; +}); + +vi.mock('../crash-monitor.js', async () => { + const actual = await vi.importActual( + '../crash-monitor.js', + ); + + return { + ...actual, + createCrashMonitor: createCrashMonitorMock, + }; +}); + +const createAppMonitor = (): AppMonitor => ({ + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + addListener: vi.fn(), + removeListener: vi.fn(), +}); + +type BridgeDouble = { + ws: object; + connection: object | null; + on: ReturnType; + off: ReturnType; + nextConnection: ReturnType; + dispose: ReturnType; +}; + +const makeBridge = (): BridgeDouble => ({ + ws: {}, + connection: null, + on: vi.fn(), + off: vi.fn(), + nextConnection: vi.fn(), + dispose: vi.fn(async () => undefined), +}); + +const makeMetroInstance = () => ({ + events: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, + waitUntilHealthy: vi.fn(async () => 'packager-status:running'), + prewarm: vi.fn(async () => false), + dispose: vi.fn(async () => undefined), +}); + +const makeLockManager = () => ({ + acquire: vi.fn(async () => ({ + release: vi.fn(async () => undefined), + })), +}); + +const makeCrashMonitor = () => ({ + watch: vi.fn(), + isAlive: vi.fn(() => false), + stop: vi.fn(async () => undefined), + start: vi.fn(async () => undefined), + reset: vi.fn(), + dispose: vi.fn(async () => undefined), +}); + +const makeDeviceState = () => ({ + permissions: { + apply: vi.fn(async () => ({ mutation: null })), + revert: vi.fn(), + resetOutstanding: vi.fn(async () => undefined), + }, +}) satisfies DeviceStateController; + +const makePlatformRunner = ( + overrides: Partial = {}, +): HarnessPlatformRunner => ({ + startApp: vi.fn(async () => undefined), + restartApp: vi.fn(async () => undefined), + stopApp: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + isAppRunning: vi.fn(async () => false), + createAppMonitor: vi.fn(() => createAppMonitor()), + ...overrides, +}); + +const globalConfig = { + rootDir: '/project', + watch: false, + watchAll: false, + collectCoverage: false, +} as JestConfig.GlobalConfig; + +const harnessConfig = { + entryPoint: 'index.js', + appRegistryComponentName: 'App', + runners: [ + { + name: 'ios', + platformId: 'ios', + runner: './__tests__/fixtures/mock-platform-runner.ts', + config: {}, + }, + ], + defaultRunner: 'ios', + metroPort: 8081, + bridgeTimeout: 1000, + platformReadyTimeout: 1000, + bundleStartTimeout: 1000, + maxAppRestarts: 1, + resetEnvironmentBetweenTestFiles: true, + detectNativeCrashes: false, + forwardClientLogs: false, + permissions: false, + plugins: [], + unstable__skipAlreadyIncludedModules: false, + unstable__enableMetroCache: false, + disableViewFlattening: false, +} as const; + +describe('createHarnessSession permission cleanup', () => { + let bridge: ReturnType; + let metroInstance: ReturnType; + let crashMonitor: ReturnType; + let lockManager: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + bridge = makeBridge(); + metroInstance = makeMetroInstance(); + crashMonitor = makeCrashMonitor(); + lockManager = makeLockManager(); + + getConfigMock.mockResolvedValue({ config: harnessConfig }); + resolveHarnessMetroPortMock.mockResolvedValue({ + config: harnessConfig, + initialMetroPort: 8081, + didFallback: false, + metroPortLease: { release: vi.fn(async () => undefined) }, + }); + getMetroInstanceMock.mockResolvedValue(metroInstance); + createHarnessBridgeMock.mockResolvedValue(bridge); + createCrashMonitorMock.mockReturnValue(crashMonitor); + }); + + afterEach(() => { + delete globalThis.__HARNESS_TEST_PLATFORM_RUNNER__; + }); + + it('resets outstanding permission mutations before restart between test files', async () => { + const deviceState = makeDeviceState(); + const platformRunner = makePlatformRunner({ + deviceState, + isAppRunning: vi.fn(async () => true), + }); + bridge.connection = {}; + crashMonitor.isAlive.mockReturnValue(true); + globalThis.__HARNESS_TEST_PLATFORM_RUNNER__ = platformRunner; + + const session = await createHarnessSession(globalConfig, { lockManager }); + const callOrder: string[] = []; + + deviceState.permissions.resetOutstanding.mockImplementation(async () => { + callOrder.push('resetOutstanding'); + }); + platformRunner.stopApp = vi.fn(async () => { + callOrder.push('stopApp'); + }); + + await session.restartApp('/test/example.ts'); + + expect(deviceState.permissions.resetOutstanding).toHaveBeenCalledTimes(1); + expect(deviceState.permissions.apply).not.toHaveBeenCalled(); + expect(callOrder).toEqual(['resetOutstanding', 'stopApp']); + + await session.dispose(); + }); + + it('reapplies configured permissions after cleanup before restart between test files', async () => { + const deviceState = makeDeviceState(); + const platformRunner = makePlatformRunner({ + deviceState, + isAppRunning: vi.fn(async () => true), + }); + bridge.connection = {}; + crashMonitor.isAlive.mockReturnValue(true); + globalThis.__HARNESS_TEST_PLATFORM_RUNNER__ = platformRunner; + + const permissionsEnabledConfig = { + ...harnessConfig, + permissions: true, + } as const; + getConfigMock.mockResolvedValue({ config: permissionsEnabledConfig }); + resolveHarnessMetroPortMock.mockResolvedValue({ + config: permissionsEnabledConfig, + initialMetroPort: 8081, + didFallback: false, + metroPortLease: { release: vi.fn(async () => undefined) }, + }); + + const session = await createHarnessSession(globalConfig, { lockManager }); + const callOrder: string[] = []; + + deviceState.permissions.apply.mockClear(); + deviceState.permissions.resetOutstanding.mockImplementation(async () => { + callOrder.push('resetOutstanding'); + }); + deviceState.permissions.apply.mockImplementation(async () => { + callOrder.push('applyConfiguredPermissions'); + return { mutation: null }; + }); + platformRunner.stopApp = vi.fn(async () => { + callOrder.push('stopApp'); + }); + + await session.restartApp('/test/example.ts'); + + expect(deviceState.permissions.resetOutstanding).toHaveBeenCalledTimes(1); + expect(deviceState.permissions.apply).toHaveBeenCalledWith({ + kind: 'permission-all', + decision: 'grant', + }); + expect(callOrder).toEqual([ + 'resetOutstanding', + 'applyConfiguredPermissions', + 'stopApp', + ]); + + await session.dispose(); + }); + + it('resets outstanding permission mutations during dispose', async () => { + const deviceState = makeDeviceState(); + const callOrder: string[] = []; + const platformRunner = makePlatformRunner({ deviceState }); + deviceState.permissions.resetOutstanding.mockImplementation(async () => { + callOrder.push('resetOutstanding'); + }); + platformRunner.dispose = vi.fn(async () => { + callOrder.push('dispose'); + }); + globalThis.__HARNESS_TEST_PLATFORM_RUNNER__ = platformRunner; + + const session = await createHarnessSession(globalConfig, { lockManager }); + + await session.dispose(); + + expect(deviceState.permissions.resetOutstanding).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['resetOutstanding', 'dispose']); + }); +}); + +describe('ios permission watchdog labels', () => { + it('keeps deny and grant prompt button labels disjoint', () => { + const source = fs.readFileSync(permissionPromptWatchdogPath, 'utf8'); + const listPattern = /static let known(Positive|Negative)ButtonLabels = \[(.*?)\]/gs; + const labelsByKind = new Map(); + + for (const match of source.matchAll(listPattern)) { + const kind = match[1]; + const body = match[2] ?? ''; + const labels = [...body.matchAll(/"([^"]+)"/g)].map((labelMatch) => labelMatch[1] ?? ''); + labelsByKind.set(kind, labels); + } + + const positiveLabels = new Set(labelsByKind.get('Positive') ?? []); + const negativeLabels = labelsByKind.get('Negative') ?? []; + + expect(negativeLabels.filter((label) => positiveLabels.has(label))).toEqual([]); + }); +}); diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index a19b2387..4bdbd8f0 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -226,6 +226,26 @@ const waitForAppReady = async ( }); }; +const resetOutstandingDevicePermissions = async ( + platformInstance: HarnessPlatformRunner, +): Promise => { + await platformInstance.deviceState?.permissions.resetOutstanding(); +}; + +const applyConfiguredDevicePermissions = async ( + platformInstance: HarnessPlatformRunner, + runtimeConfig: HarnessConfig, +): Promise => { + if (!runtimeConfig.permissions) { + return; + } + + await platformInstance.deviceState?.permissions.apply({ + kind: 'permission-all', + decision: 'grant', + }); +}; + const getDefaultResourceLockKey = (platform: HarnessPlatform): string => `${platform.platformId}:${platform.name}`; @@ -412,7 +432,11 @@ export const createHarnessSession = async ( const getCurrentRunId = () => currentRun?.runId; const clientLogCollector = createClientLogCollector(); - const context: HarnessContext = { platform }; + let platformInstance!: HarnessPlatformRunner; + const context: HarnessContext = { + platform, + getDeviceState: () => platformInstance?.deviceState, + }; const bridge = await createHarnessBridge({ noServer: true, @@ -422,7 +446,6 @@ export const createHarnessSession = async ( sessionLogger.debug('bridge initialized on Metro websocket path %s', HARNESS_BRIDGE_PATH); let metroInstance: MetroInstance; - let platformInstance: HarnessPlatformRunner; try { [metroInstance, platformInstance] = await Promise.all([ @@ -527,6 +550,7 @@ export const createHarnessSession = async ( const disposeOnce = async (reason: 'normal' | 'abort' | 'error') => { sessionLogger.debug('disposing session (reason=%s)', reason); let hookError: unknown; + let permissionResetError: unknown; try { await hooks.drain(); @@ -575,6 +599,12 @@ export const createHarnessSession = async ( } let cleanupError: unknown; + try { + await resetOutstandingDevicePermissions(platformInstance); + } catch (error) { + permissionResetError = error; + } + try { await Promise.all([ crashMonitor.dispose(), @@ -593,6 +623,7 @@ export const createHarnessSession = async ( sessionLogger.debug('session resources disposed'); if (hookError) throw hookError; + if (permissionResetError) throw permissionResetError; if (cleanupError) throw cleanupError; }; @@ -655,6 +686,12 @@ export const createHarnessSession = async ( testFilePath ? 'stop-and-ensure-ready' : 'direct-restart', ); + await resetOutstandingDevicePermissions(platformInstance); + + if (testFilePath) { + await applyConfiguredDevicePermissions(platformInstance, runtimeConfig); + } + if (testFilePath) { await platformInstance.stopApp(); } else { diff --git a/packages/platform-android/README.md b/packages/platform-android/README.md index 6932ccab..16e37abf 100644 --- a/packages/platform-android/README.md +++ b/packages/platform-android/README.md @@ -52,6 +52,19 @@ const config = { export default config; ``` +## Permissions + +Android runners support the shared `device.permissions` fixture API. + +```ts +import { device } from 'react-native-harness'; + +await device.permissions.grant('camera'); +await device.permissions.denyAll(); +``` + +Harness resolves bulk operations from the app's declared dangerous permissions and applies them through ADB. Existing config-driven `permissions: true` support is routed through the same controller as `grantAll()`. + ## API ### `androidPlatform(config)` diff --git a/packages/platform-android/src/__tests__/instance.test.ts b/packages/platform-android/src/__tests__/instance.test.ts index 3ead8cd6..8effbdf0 100644 --- a/packages/platform-android/src/__tests__/instance.test.ts +++ b/packages/platform-android/src/__tests__/instance.test.ts @@ -578,12 +578,16 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(adb, 'getDeclaredDangerousPermissions').mockResolvedValue([ + 'android.permission.CAMERA', + ]); + vi.spyOn(adb, 'isPermissionGranted').mockResolvedValue(false); + const setPermissions = vi + .spyOn(adb, 'setPermissions') + .mockResolvedValue(undefined); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( undefined, ); - const grantPermissions = vi - .spyOn(adb, 'grantPermissions') - .mockResolvedValue(undefined); const harnessConfigWithPermissions = { ...harnessConfig, @@ -610,9 +614,12 @@ describe('Android platform instance', () => { init, ); - expect(grantPermissions).toHaveBeenCalledWith( + expect(setPermissions).toHaveBeenCalledWith( 'emulator-5554', 'com.harnessplayground', + ['android.permission.CAMERA'], + 'grant', + { bestEffort: true }, ); }); @@ -628,12 +635,16 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(adb, 'getDeclaredDangerousPermissions').mockResolvedValue([ + 'android.permission.CAMERA', + ]); + vi.spyOn(adb, 'isPermissionGranted').mockResolvedValue(false); + const setPermissions = vi + .spyOn(adb, 'setPermissions') + .mockResolvedValue(undefined); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( undefined, ); - const grantPermissions = vi - .spyOn(adb, 'grantPermissions') - .mockResolvedValue(undefined); await getAndroidEmulatorPlatformInstance( { @@ -655,7 +666,7 @@ describe('Android platform instance', () => { init, ); - expect(grantPermissions).not.toHaveBeenCalled(); + expect(setPermissions).not.toHaveBeenCalled(); }); it('grants permissions when permissions are enabled for physical device', async () => { @@ -668,12 +679,16 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(adb, 'getDeclaredDangerousPermissions').mockResolvedValue([ + 'android.permission.CAMERA', + ]); + vi.spyOn(adb, 'isPermissionGranted').mockResolvedValue(false); + const setPermissions = vi + .spyOn(adb, 'setPermissions') + .mockResolvedValue(undefined); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( undefined, ); - const grantPermissions = vi - .spyOn(adb, 'grantPermissions') - .mockResolvedValue(undefined); const harnessConfigWithPermissions = { ...harnessConfig, @@ -694,9 +709,12 @@ describe('Android platform instance', () => { harnessConfigWithPermissions, ); - expect(grantPermissions).toHaveBeenCalledWith( + expect(setPermissions).toHaveBeenCalledWith( '012345', 'com.harnessplayground', + ['android.permission.CAMERA'], + 'grant', + { bestEffort: true }, ); }); }); diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 9b943901..6826d102 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -384,7 +384,7 @@ export const getDeviceInfo = async ( return { manufacturer, model }; }; -const getRequestedPermissions = async ( +export const getRequestedPermissions = async ( adbId: string, bundleId: string, ): Promise => { @@ -432,7 +432,9 @@ const getRequestedPermissions = async ( return [...requestedPermissions]; }; -const getDangerousPermissions = async (adbId: string): Promise> => { +export const getDangerousPermissions = async ( + adbId: string, +): Promise> => { const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', adbId, @@ -457,6 +459,88 @@ const getDangerousPermissions = async (adbId: string): Promise> => { return dangerousPermissions; }; +export const getDeclaredDangerousPermissions = async ( + adbId: string, + bundleId: string, +): Promise => { + const [requestedPermissions, dangerousPermissions] = await Promise.all([ + getRequestedPermissions(adbId, bundleId), + getDangerousPermissions(adbId), + ]); + + return requestedPermissions.filter((permission) => + dangerousPermissions.has(permission), + ); +}; + +export const isPermissionGranted = async ( + adbId: string, + bundleId: string, + permission: string, +): Promise => { + const { stdout } = await spawn(getAdbBinaryPath(), [ + '-s', + adbId, + 'shell', + 'pm', + 'check-permission', + bundleId, + permission, + ]); + const normalized = stdout.trim().toLowerCase(); + + return normalized.includes('granted=true') || normalized === 'granted'; +}; + +export const setPermissions = async ( + adbId: string, + bundleId: string, + permissions: readonly string[], + decision: 'grant' | 'deny' | 'reset', + options?: { bestEffort?: boolean }, +): Promise => { + const failures: unknown[] = []; + + for (const permission of permissions) { + const args = [ + '-s', + adbId, + 'shell', + 'pm', + decision === 'grant' ? 'grant' : 'revoke', + bundleId, + permission, + ]; + + try { + await spawn(getAdbBinaryPath(), args); + } catch (error) { + if (!options?.bestEffort) { + throw error; + } + + failures.push(error); + androidAdbLogger.debug('setPermissions:best-effort-failure %o', { + adbId, + bundleId, + permission, + decision, + error, + }); + } + } + + if (failures.length > 0) { + androidAdbLogger.debug('setPermissions:completed-with-failures %o', { + adbId, + bundleId, + permissions, + decision, + failureCount: failures.length, + }); + } +}; + export const isBootCompleted = async (adbId: string): Promise => { try { const bootCompleted = await getShellProperty(adbId, 'sys.boot_completed'); @@ -844,18 +928,11 @@ export const grantPermissions = async ( throw new AdbAppNotInstalledError(bundleId, adbId); } - const [requestedPermissions, dangerousPermissions] = await Promise.all([ - getRequestedPermissions(adbId, bundleId), - getDangerousPermissions(adbId), - ]); - const permissions = requestedPermissions.filter((permission) => - dangerousPermissions.has(permission), - ); + const permissions = await getDeclaredDangerousPermissions(adbId, bundleId); androidAdbLogger.debug('grantPermissions:resolved %o', { adbId, bundleId, - requestedPermissions, permissions, }); @@ -867,26 +944,14 @@ export const grantPermissions = async ( return; } - const grantCommands = permissions.map((permission) => [ - '-s', - adbId, - 'shell', - 'pm', - 'grant', - bundleId, - permission, - ]); - try { - androidAdbLogger.debug('grantPermissions:commands %o', { + androidAdbLogger.debug('grantPermissions:permissions %o', { adbId, bundleId, - grantCommands, + permissions, }); - await Promise.all( - grantCommands.map((args) => spawn(getAdbBinaryPath(), args as string[])), - ); + await setPermissions(adbId, bundleId, permissions, 'grant'); androidAdbLogger.debug('grantPermissions:success %o', { adbId, diff --git a/packages/platform-android/src/device-state.ts b/packages/platform-android/src/device-state.ts new file mode 100644 index 00000000..3afde89f --- /dev/null +++ b/packages/platform-android/src/device-state.ts @@ -0,0 +1,215 @@ +import type { + DevicePermissionCommand, + DeviceStateController, + DeviceStateMutation, +} from '@react-native-harness/platforms'; +import * as adb from './adb.js'; + +const ANDROID_PERMISSION_ALIASES: Record = { + bluetooth: [ + 'android.permission.BLUETOOTH_CONNECT', + 'android.permission.BLUETOOTH_SCAN', + ], + calendar: [ + 'android.permission.READ_CALENDAR', + 'android.permission.WRITE_CALENDAR', + ], + camera: ['android.permission.CAMERA'], + contacts: [ + 'android.permission.READ_CONTACTS', + 'android.permission.WRITE_CONTACTS', + ], + location: [ + 'android.permission.ACCESS_FINE_LOCATION', + 'android.permission.ACCESS_COARSE_LOCATION', + ], + mediaLibrary: [ + 'android.permission.READ_MEDIA_IMAGES', + 'android.permission.READ_MEDIA_VIDEO', + 'android.permission.READ_MEDIA_AUDIO', + ], + microphone: ['android.permission.RECORD_AUDIO'], + notifications: ['android.permission.POST_NOTIFICATIONS'], + phone: [ + 'android.permission.CALL_PHONE', + 'android.permission.ANSWER_PHONE_CALLS', + ], + sms: [ + 'android.permission.READ_SMS', + 'android.permission.RECEIVE_SMS', + 'android.permission.SEND_SMS', + ], + storage: [ + 'android.permission.READ_EXTERNAL_STORAGE', + 'android.permission.WRITE_EXTERNAL_STORAGE', + ], +}; + +type AndroidPermissionSnapshot = { + kind: 'permission-set'; + permissions: string[]; + previousGranted: string[]; +}; + +type AndroidPermissionMutation = DeviceStateMutation & { + snapshot: AndroidPermissionSnapshot; +}; + +const normalizePermissionName = (name: string): string => { + return name.trim(); +}; + +const resolvePermissions = async (options: { + adbId: string; + bundleId: string; + command: DevicePermissionCommand; +}): Promise => { + if (options.command.kind === 'permission-all') { + return adb.getDeclaredDangerousPermissions(options.adbId, options.bundleId); + } + + const normalized = normalizePermissionName(options.command.permission); + const aliases = ANDROID_PERMISSION_ALIASES[normalized]; + + if (aliases) { + return aliases; + } + + return [normalized]; +}; + +const snapshotPermissions = async (options: { + adbId: string; + bundleId: string; + permissions: readonly string[]; +}): Promise => { + const granted = await Promise.all( + options.permissions.map(async (permission) => { + const isGranted = await adb.isPermissionGranted( + options.adbId, + options.bundleId, + permission, + ); + + return isGranted ? permission : null; + }), + ); + + return granted.filter((permission): permission is string => permission != null); +}; + +const applyPermissionSnapshot = async (options: { + adbId: string; + bundleId: string; + snapshot: AndroidPermissionSnapshot; +}): Promise => { + const grantedSet = new Set(options.snapshot.previousGranted); + const toGrant = options.snapshot.permissions.filter((permission) => + grantedSet.has(permission), + ); + const toRevoke = options.snapshot.permissions.filter( + (permission) => !grantedSet.has(permission), + ); + + if (toGrant.length > 0) { + await adb.setPermissions(options.adbId, options.bundleId, toGrant, 'grant', { + bestEffort: true, + }); + } + + if (toRevoke.length > 0) { + await adb.setPermissions(options.adbId, options.bundleId, toRevoke, 'reset', { + bestEffort: true, + }); + } +}; + +export const createAndroidDeviceState = (options: { + adbId: string; + bundleId: string; +}): DeviceStateController => { + const outstandingMutations = new Map(); + let mutationCounter = 0; + + const rememberMutation = ( + snapshot: AndroidPermissionSnapshot, + ): AndroidPermissionMutation => { + const id = `android-permissions-${++mutationCounter}`; + const mutation: AndroidPermissionMutation = { + id, + snapshot, + revert: async () => { + await controller.permissions.revert(id); + }, + }; + + outstandingMutations.set(id, mutation); + return mutation; + }; + + const controller: DeviceStateController = { + permissions: { + apply: async (command) => { + const permissions = await resolvePermissions({ + adbId: options.adbId, + bundleId: options.bundleId, + command, + }); + + if (permissions.length === 0) { + return { mutation: null }; + } + + const previousGranted = await snapshotPermissions({ + adbId: options.adbId, + bundleId: options.bundleId, + permissions, + }); + + await adb.setPermissions( + options.adbId, + options.bundleId, + permissions, + command.decision, + { bestEffort: command.kind === 'permission-all' }, + ); + + const mutation = rememberMutation({ + kind: 'permission-set', + permissions, + previousGranted, + }); + + return { mutation }; + }, + revert: async (mutationId) => { + const mutation = outstandingMutations.get(mutationId); + + if (!mutation) { + return; + } + + outstandingMutations.delete(mutationId); + await applyPermissionSnapshot({ + adbId: options.adbId, + bundleId: options.bundleId, + snapshot: mutation.snapshot, + }); + }, + resetOutstanding: async () => { + const mutations = [...outstandingMutations.values()].reverse(); + outstandingMutations.clear(); + + for (const mutation of mutations) { + await applyPermissionSnapshot({ + adbId: options.adbId, + bundleId: options.bundleId, + snapshot: mutation.snapshot, + }); + } + }, + }, + }; + + return controller; +}; diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index d28eb177..d9002f24 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -34,6 +34,7 @@ import { import { isInteractive } from '@react-native-harness/tools'; import fs from 'node:fs'; import type { AppMonitor } from '@react-native-harness/platforms'; +import { createAndroidDeviceState } from './device-state.js'; const androidInstanceLogger = logger.child('android-instance'); @@ -276,12 +277,20 @@ export const getAndroidEmulatorPlatformInstance = async ( } const appUid = await configureAndroidRuntime(adbId, config, harnessConfig); + const deviceState = createAndroidDeviceState({ + adbId, + bundleId: config.bundleId, + }); if (permissionsEnabled) { - await adb.grantPermissions(adbId, config.bundleId); + await deviceState.permissions.apply({ + kind: 'permission-all', + decision: 'grant', + }); } return { + deviceState, startApp: async (options) => { await adb.startApp( adbId, @@ -356,12 +365,20 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( } const appUid = await configureAndroidRuntime(adbId, config, harnessConfig); + const deviceState = createAndroidDeviceState({ + adbId, + bundleId: config.bundleId, + }); if (permissionsEnabled) { - await adb.grantPermissions(adbId, config.bundleId); + await deviceState.permissions.apply({ + kind: 'permission-all', + decision: 'grant', + }); } return { + deviceState, startApp: async (options) => { await adb.startApp( adbId, diff --git a/packages/platform-ios/README.md b/packages/platform-ios/README.md index 497e8d97..0f5d4f87 100644 --- a/packages/platform-ios/README.md +++ b/packages/platform-ios/README.md @@ -47,6 +47,23 @@ const config = { export default config; ``` +## Permissions + +Apple runners support the shared `device.permissions` fixture API with platform-specific behavior. + +```ts +import { device } from 'react-native-harness'; + +await device.permissions.grant('photos'); +await device.permissions.resetAll(); +``` + +- iOS simulator: supported permissions are applied through `xcrun simctl privacy`. +- iOS physical device: named permission calls warn and no-op; `grantAll()`, `denyAll()`, and `resetAll()` configure XCTest Agent prompt handling for future prompts. +- iOS simulator `camera` is intentionally unsupported in v1. + +Existing config-driven `permissions: true` support is routed through the same controller as `grantAll()`. + ## API ### `applePlatform(config)` diff --git a/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts b/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts index eac3fc88..c34dc097 100644 --- a/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts +++ b/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - DEFAULT_METRO_PORT, type Config as HarnessConfig, } from '@react-native-harness/config'; import * as simctl from '../xcrun/simctl.js'; @@ -22,11 +21,8 @@ import { getAppleSimulatorPlatformInstance, } from '../instance.js'; -const harnessConfig = { - metroPort: DEFAULT_METRO_PORT, -} as HarnessConfig; const harnessConfigWithPermissionsEnabled = { - metroPort: DEFAULT_METRO_PORT, + metroPort: 8081, permissions: true, } as HarnessConfig; @@ -41,7 +37,7 @@ describe('iOS XCTest agent runner integration', () => { }); }); - it('starts the simulator XCTest agent during platform initialization when permissions are enabled', async () => { + it('does not start the simulator XCTest agent during platform initialization when permissions are enabled', async () => { vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); @@ -73,24 +69,12 @@ describe('iOS XCTest agent runner integration', () => { await instance.startApp(); await instance.dispose(); - expect(mocks.createXCTestAgentController).toHaveBeenCalledWith({ - appBundleId: 'com.harnessplayground', - capabilities: [ - expect.objectContaining({ - getLaunchEnvironment: expect.any(Function), - }), - ], - target: { - kind: 'simulator', - id: 'sim-udid', - }, - }); - expect(mocks.prepare).not.toHaveBeenCalled(); - expect(mocks.ensureStarted).toHaveBeenCalledTimes(1); - expect(mocks.dispose).toHaveBeenCalledTimes(1); + expect(mocks.createXCTestAgentController).not.toHaveBeenCalled(); + expect(mocks.ensureStarted).not.toHaveBeenCalled(); + expect(mocks.dispose).not.toHaveBeenCalled(); }); - it('starts the physical-device XCTest agent during platform initialization when permissions are enabled', async () => { + it('does not start the physical-device XCTest agent without code signing', async () => { vi.spyOn(devicectl, 'getDevice').mockResolvedValue({ identifier: 'device-udid', deviceProperties: { @@ -122,48 +106,9 @@ describe('iOS XCTest agent runner integration', () => { await instance.restartApp(); await instance.dispose(); - expect(mocks.createXCTestAgentController).toHaveBeenCalledWith({ - appBundleId: 'com.harnessplayground', - capabilities: [ - expect.objectContaining({ - getLaunchEnvironment: expect.any(Function), - }), - ], - target: { - kind: 'device', - id: 'device-udid', - }, - }); - expect(mocks.prepare).not.toHaveBeenCalled(); - expect(mocks.ensureStarted).toHaveBeenCalledTimes(1); - expect(mocks.dispose).toHaveBeenCalledTimes(1); - }); - - it('does not start the simulator XCTest agent when permissions are disabled', async () => { - vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); - vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); - vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); - vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( - undefined, - ); - - await getAppleSimulatorPlatformInstance( - { - name: 'ios', - device: { - type: 'simulator', - name: 'iPhone 16 Pro', - systemVersion: '18.0', - }, - bundleId: 'com.harnessplayground', - }, - harnessConfig, - { - signal: new AbortController().signal, - }, - ); - expect(mocks.createXCTestAgentController).not.toHaveBeenCalled(); + expect(mocks.prepare).not.toHaveBeenCalled(); expect(mocks.ensureStarted).not.toHaveBeenCalled(); + expect(mocks.dispose).not.toHaveBeenCalled(); }); }); diff --git a/packages/platform-ios/src/__tests__/instance.test.ts b/packages/platform-ios/src/__tests__/instance.test.ts index 7dd0864f..5fac7f94 100644 --- a/packages/platform-ios/src/__tests__/instance.test.ts +++ b/packages/platform-ios/src/__tests__/instance.test.ts @@ -13,6 +13,25 @@ import { HarnessAppPathError } from '../errors.js'; import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { createDeviceXCTestAgentTransport } from '../xctest-agent-transport-device.js'; + +vi.mock('../xctest-agent-transport-device.js', () => ({ + createDeviceXCTestAgentTransport: vi.fn(() => ({ + dispose: vi.fn(async () => undefined), + request: vi.fn(), + })), +})); + +const xctestAgentClientMocks = vi.hoisted(() => ({ + configurePermissions: vi.fn(async () => ({ permissionPromptPolicy: 'grant-all' })), + createXCTestAgentClient: vi.fn(), + dispose: vi.fn(async () => undefined), + getPermissionsConfig: vi.fn(async () => ({ permissionPromptPolicy: 'disabled' })), +})); + +vi.mock('../xctest-agent-client.js', () => ({ + createXCTestAgentClient: xctestAgentClientMocks.createXCTestAgentClient, +})); const xctestAgentMocks = vi.hoisted(() => ({ createXCTestAgentController: vi.fn(), @@ -51,6 +70,15 @@ describe('iOS platform instance dependency validation', () => { stop: vi.fn(async () => undefined), dispose: xctestAgentMocks.dispose, }); + xctestAgentClientMocks.createXCTestAgentClient.mockReturnValue({ + configurePermissions: xctestAgentClientMocks.configurePermissions, + dispose: xctestAgentClientMocks.dispose, + getPermissionsConfig: xctestAgentClientMocks.getPermissionsConfig, + health: vi.fn(async () => ({ + permissions: { permissionPromptPolicy: 'disabled' }, + status: 'ok', + })), + }); }); it('does not require extra dependencies before creating a simulator instance', async () => { @@ -101,6 +129,40 @@ describe('iOS platform instance dependency validation', () => { expect(xctestAgentMocks.createXCTestAgentController).not.toHaveBeenCalled(); }); + it('grants supported simulator permissions via simctl privacy when permissions are enabled', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined, + ); + const setPrivacyPermission = vi + .spyOn(simctl, 'setPrivacyPermission') + .mockResolvedValue(undefined); + + await getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfigWithPermissionsEnabled, + init, + ); + + expect(setPrivacyPermission).toHaveBeenCalledWith({ + bundleId: 'com.harnessplayground', + decision: 'grant', + service: 'all', + udid: 'sim-udid', + }); + expect(xctestAgentMocks.createXCTestAgentController).not.toHaveBeenCalled(); + }); + it('discovers the physical device directly through devicectl', async () => { const getDevice = vi.spyOn(devicectl, 'getDevice').mockResolvedValue({ identifier: 'physical-device-id', @@ -159,6 +221,53 @@ describe('iOS platform instance dependency validation', () => { expect(xctestAgentMocks.createXCTestAgentController).not.toHaveBeenCalled(); }); + it('lazily starts the physical-device XCTest agent when permissions are enabled', async () => { + vi.spyOn(devicectl, 'getDevice').mockResolvedValue({ + identifier: 'physical-device-id', + deviceProperties: { + name: 'My iPhone', + osVersionNumber: '18.0', + }, + hardwareProperties: { + marketingName: 'iPhone', + productType: 'iPhone17,1', + udid: '00008140-001600222422201C', + }, + }); + vi.spyOn(devicectl, 'isAppInstalled').mockResolvedValue(true); + + await getApplePhysicalDevicePlatformInstance( + { + name: 'ios-device', + device: { + type: 'physical', + name: 'My iPhone', + codeSign: { teamId: 'TESTTEAM01' }, + }, + bundleId: 'com.harnessplayground', + }, + harnessConfigWithPermissionsEnabled, + ); + + expect(xctestAgentMocks.createXCTestAgentController).toHaveBeenCalledWith({ + appBundleId: 'com.harnessplayground', + port: 49200, + target: { + kind: 'device', + id: '00008140-001600222422201C', + codeSign: { teamId: 'TESTTEAM01' }, + }, + }); + expect(xctestAgentMocks.ensureStarted).toHaveBeenCalledTimes(1); + expect(createDeviceXCTestAgentTransport).toHaveBeenCalledWith({ + deviceId: 'physical-device-id', + port: 49200, + }); + expect(xctestAgentClientMocks.configurePermissions).toHaveBeenCalledWith({ + permissionPromptPolicy: 'grant-all', + }); + }); + it('skips physical crash monitoring setup when native crash detection is disabled', async () => { vi.spyOn(devicectl, 'getDevice').mockResolvedValue({ identifier: 'physical-device-id', diff --git a/packages/platform-ios/src/__tests__/simctl.test.ts b/packages/platform-ios/src/__tests__/simctl.test.ts index 5a59c0e8..88c13320 100644 --- a/packages/platform-ios/src/__tests__/simctl.test.ts +++ b/packages/platform-ios/src/__tests__/simctl.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import * as tools from '@react-native-harness/tools'; -import { diagnose, streamLogs, waitForBoot } from '../xcrun/simctl.js'; +import { + diagnose, + setPrivacyPermission, + streamLogs, + waitForBoot, +} from '../xcrun/simctl.js'; describe('simctl startup', () => { beforeEach(() => { @@ -77,4 +82,26 @@ describe('simctl startup', () => { } ); }); + + it('maps deny decisions to simctl revoke for privacy permissions', async () => { + const spawnSpy = vi + .spyOn(tools, 'spawn') + .mockResolvedValue({} as Awaited>); + + await setPrivacyPermission({ + bundleId: 'com.harnessplayground', + decision: 'deny', + service: 'camera', + udid: 'sim-udid', + }); + + expect(spawnSpy).toHaveBeenCalledWith('xcrun', [ + 'simctl', + 'privacy', + 'sim-udid', + 'revoke', + 'camera', + 'com.harnessplayground', + ]); + }); }); diff --git a/packages/platform-ios/src/__tests__/xctest-agent-capabilities.test.ts b/packages/platform-ios/src/__tests__/xctest-agent-capabilities.test.ts index 0aceb42c..4b5a205e 100644 --- a/packages/platform-ios/src/__tests__/xctest-agent-capabilities.test.ts +++ b/packages/platform-ios/src/__tests__/xctest-agent-capabilities.test.ts @@ -1,27 +1,27 @@ import { describe, expect, it } from 'vitest'; -import { createPermissionPromptAutoAcceptCapability } from '../xctest-agent-capabilities.js'; +import { createPermissionPromptCapability } from '../xctest-agent-capabilities.js'; describe('xctest agent capabilities', () => { - it('enables best-effort permission prompt auto-accept through launch environment', () => { - const capability = createPermissionPromptAutoAcceptCapability(); + it('sets permission prompt policy through launch environment', () => { + const capability = createPermissionPromptCapability('deny-all'); expect(capability.getLaunchEnvironment?.()).toEqual({ - HARNESS_XCTEST_AGENT_AUTO_ACCEPT_PERMISSIONS: '1', + HARNESS_XCTEST_AGENT_PERMISSION_PROMPT_POLICY: 'deny-all', }); }); - it('enables permission auto-accept in the runtime configuration', () => { - const capability = createPermissionPromptAutoAcceptCapability(); + it('writes permission prompt policy into the runtime configuration', () => { + const capability = createPermissionPromptCapability('grant-all'); expect( capability.updateConfiguration?.({ permissions: { - autoAcceptPermissions: false, + permissionPromptPolicy: 'disabled', }, }), ).toEqual({ permissions: { - autoAcceptPermissions: true, + permissionPromptPolicy: 'grant-all', }, }); }); diff --git a/packages/platform-ios/src/__tests__/xctest-agent-client.test.ts b/packages/platform-ios/src/__tests__/xctest-agent-client.test.ts index 6defc1aa..23d58dc8 100644 --- a/packages/platform-ios/src/__tests__/xctest-agent-client.test.ts +++ b/packages/platform-ios/src/__tests__/xctest-agent-client.test.ts @@ -9,7 +9,7 @@ describe('xctest-agent client', () => { .mockResolvedValueOnce({ body: JSON.stringify({ permissions: { - autoAcceptPermissions: false, + permissionPromptPolicy: 'disabled', }, status: 'ok', }), @@ -19,7 +19,7 @@ describe('xctest-agent client', () => { .mockResolvedValueOnce({ body: JSON.stringify({ permissions: { - autoAcceptPermissions: true, + permissionPromptPolicy: 'grant-all', }, }), headers: {}, @@ -28,7 +28,7 @@ describe('xctest-agent client', () => { .mockResolvedValueOnce({ body: JSON.stringify({ permissions: { - autoAcceptPermissions: true, + permissionPromptPolicy: 'grant-all', }, }), headers: {}, @@ -42,19 +42,19 @@ describe('xctest-agent client', () => { await expect(client.health()).resolves.toEqual({ permissions: { - autoAcceptPermissions: false, + permissionPromptPolicy: 'disabled', }, status: 'ok', }); await expect( client.configurePermissions({ - autoAcceptPermissions: true, + permissionPromptPolicy: 'grant-all', }), ).resolves.toEqual({ - autoAcceptPermissions: true, + permissionPromptPolicy: 'grant-all', }); await expect(client.getPermissionsConfig()).resolves.toEqual({ - autoAcceptPermissions: true, + permissionPromptPolicy: 'grant-all', }); expect(request).toHaveBeenNthCalledWith(1, { @@ -66,7 +66,7 @@ describe('xctest-agent client', () => { method: 'POST', path: '/permissions/configure', body: JSON.stringify({ - autoAcceptPermissions: true, + permissionPromptPolicy: 'grant-all', }), }); expect(request).toHaveBeenNthCalledWith(3, { diff --git a/packages/platform-ios/src/__tests__/xctest-agent.test.ts b/packages/platform-ios/src/__tests__/xctest-agent.test.ts index 81b8390a..4b227220 100644 --- a/packages/platform-ios/src/__tests__/xctest-agent.test.ts +++ b/packages/platform-ios/src/__tests__/xctest-agent.test.ts @@ -8,12 +8,12 @@ import { fileURLToPath } from 'node:url'; const mocks = vi.hoisted(() => ({ activeAgentStops: [] as Array<() => void>, - configurePermissions: vi.fn(async () => ({ autoAcceptPermissions: true })), + configurePermissions: vi.fn(async () => ({ permissionPromptPolicy: 'grant-all' })), disposeClient: vi.fn(async () => undefined), disposeTransport: vi.fn(async () => undefined), health: vi.fn(async () => ({ permissions: { - autoAcceptPermissions: false, + permissionPromptPolicy: 'disabled', }, status: 'ok', })), @@ -409,14 +409,14 @@ describe('xctest-agent orchestration', () => { getLaunchEnvironment: () => ({ HARNESS_XCTEST_AGENT_MODE: 'test', }), - updateConfiguration: (configuration) => ({ - ...configuration, - permissions: { - ...configuration.permissions, - autoAcceptPermissions: true, - }, - }), - }, + updateConfiguration: (configuration) => ({ + ...configuration, + permissions: { + ...configuration.permissions, + permissionPromptPolicy: 'grant-all', + }, + }), + }, ], }); @@ -443,7 +443,7 @@ describe('xctest-agent orchestration', () => { }); expect(mocks.health).toHaveBeenCalledTimes(1); expect(mocks.configurePermissions).toHaveBeenCalledWith({ - autoAcceptPermissions: true, + permissionPromptPolicy: 'grant-all', }); const logDirectories = fs.readdirSync( path.join(tempProjectRoot, '.harness', 'logs') @@ -702,6 +702,7 @@ describe('xctest-agent orchestration', () => { expect(mocks.kill).not.toHaveBeenCalled(); }); + }); const rmBuildRoot = () => { diff --git a/packages/platform-ios/src/device-state.ts b/packages/platform-ios/src/device-state.ts new file mode 100644 index 00000000..5f456d30 --- /dev/null +++ b/packages/platform-ios/src/device-state.ts @@ -0,0 +1,286 @@ +import type { + DeviceStateController, + DeviceStateMutation, +} from '@react-native-harness/platforms'; +import * as simctl from './xcrun/simctl.js'; +import type { + XCTestAgentClient, + XCTestAgentPermissionsConfiguration, +} from './xctest-agent-client.js'; + +const IOS_SIMULATOR_PERMISSION_ALIASES: Record = { + mediaLibrary: 'media-library', +}; + +const IOS_SIMULATOR_SUPPORTED_PERMISSIONS = new Set([ + 'all', + 'calendar', + 'contacts', + 'contacts-limited', + 'location', + 'location-always', + 'photos', + 'photos-add', + 'media-library', + 'microphone', + 'motion', + 'reminders', + 'siri', +]); + +type IosSimulatorPermissionSnapshot = { + kind: 'simulator'; + service: string; + revertDecision: 'grant' | 'deny' | 'reset'; +}; + +type IosPhysicalPermissionSnapshot = { + kind: 'physical'; + configuration: XCTestAgentPermissionsConfiguration; +}; + +type IosPermissionSnapshot = + | IosSimulatorPermissionSnapshot + | IosPhysicalPermissionSnapshot; + +type IosPermissionMutation = DeviceStateMutation & { + snapshot: IosPermissionSnapshot; +}; + +type IosPhysicalDeviceStateClientProvider = () => Promise; + +const unsupportedPhysicalPermissionWarning = ( + decision: 'grant' | 'deny' | 'reset', + permission: string, +): string => { + const methodName = + decision === 'grant' + ? 'grant' + : decision === 'deny' + ? 'revoke' + : 'reset'; + + return `device.permissions.${methodName}("${permission}") cannot mutate permission state on physical iOS devices. Harness only supports deterministic iOS permission mutation on simulators.`; +}; + +const physicalBulkWarning = (decision: 'grant' | 'deny' | 'reset'): string => { + if (decision === 'grant') { + return 'device.permissions.grantAll() on physical iOS enables XCTest Agent prompt auto-accept. It cannot pre-grant existing permission state.'; + } + + if (decision === 'deny') { + return 'device.permissions.denyAll() on physical iOS enables XCTest Agent prompt auto-deny. It cannot revoke existing permission state.'; + } + + return 'device.permissions.resetAll() on physical iOS disables XCTest Agent permission prompt handling and restores default prompt behavior.'; +}; + +const getPolicyForDecision = ( + decision: 'grant' | 'deny' | 'reset', +): XCTestAgentPermissionsConfiguration['permissionPromptPolicy'] => { + if (decision === 'grant') { + return 'grant-all'; + } + + if (decision === 'deny') { + return 'deny-all'; + } + + return 'disabled'; +}; + +const getRevertDecision = ( + decision: 'grant' | 'deny' | 'reset', +): 'grant' | 'deny' | 'reset' => { + if (decision === 'grant') { + return 'reset'; + } + + if (decision === 'deny') { + return 'reset'; + } + + return 'reset'; +}; + +const normalizeSimulatorPermission = (permission: string): string => { + const trimmed = permission.trim(); + return IOS_SIMULATOR_PERMISSION_ALIASES[trimmed] ?? trimmed; +}; + +export const createIosSimulatorDeviceState = (options: { + udid: string; + bundleId: string; + onWarning?: (warning: string) => void; +}): DeviceStateController => { + const outstandingMutations = new Map(); + let mutationCounter = 0; + + const revertSnapshot = async (snapshot: IosSimulatorPermissionSnapshot) => { + await simctl.setPrivacyPermission({ + udid: options.udid, + bundleId: options.bundleId, + service: snapshot.service, + decision: snapshot.revertDecision, + }); + }; + + const controller: DeviceStateController = { + permissions: { + apply: async (command) => { + const service = + command.kind === 'permission-all' + ? 'all' + : normalizeSimulatorPermission(command.permission); + + if (!IOS_SIMULATOR_SUPPORTED_PERMISSIONS.has(service)) { + const warning = `device.permissions.${command.decision === 'deny' ? 'revoke' : command.decision}("${service}") is not supported on iOS simulators. Harness supports only simctl privacy services in v1.`; + options.onWarning?.(warning); + return { mutation: null, warning }; + } + + await simctl.setPrivacyPermission({ + udid: options.udid, + bundleId: options.bundleId, + service, + decision: command.decision, + }); + + const id = `ios-simulator-permissions-${++mutationCounter}`; + const mutation: IosPermissionMutation = { + id, + snapshot: { + kind: 'simulator', + service, + revertDecision: getRevertDecision(command.decision), + }, + revert: async () => { + await controller.permissions.revert(id); + }, + }; + + outstandingMutations.set(id, mutation); + return { mutation }; + }, + revert: async (mutationId) => { + const mutation = outstandingMutations.get(mutationId); + + if (!mutation || mutation.snapshot.kind !== 'simulator') { + return; + } + + outstandingMutations.delete(mutationId); + await revertSnapshot(mutation.snapshot); + }, + resetOutstanding: async () => { + const mutations = [...outstandingMutations.values()].reverse(); + outstandingMutations.clear(); + + for (const mutation of mutations) { + if (mutation.snapshot.kind === 'simulator') { + await revertSnapshot(mutation.snapshot); + } + } + }, + }, + }; + + return controller; +}; + +export const createIosPhysicalDeviceState = (options: { + getClient?: IosPhysicalDeviceStateClientProvider; + onWarning?: (warning: string) => void; +}): DeviceStateController => { + const outstandingMutations = new Map(); + let mutationCounter = 0; + + const getClient = async (): Promise => { + return (await options.getClient?.()) ?? null; + }; + + const configurePermissions = async ( + configuration: XCTestAgentPermissionsConfiguration, + ): Promise => { + const client = await getClient(); + + if (!client) { + return; + } + + await client.configurePermissions(configuration); + }; + + const getCurrentConfiguration = async (): Promise => { + const client = await getClient(); + + if (!client) { + return { permissionPromptPolicy: 'disabled' }; + } + + return client.getPermissionsConfig(); + }; + + const controller: DeviceStateController = { + permissions: { + apply: async (command) => { + if (command.kind === 'permission') { + const warning = unsupportedPhysicalPermissionWarning( + command.decision, + command.permission, + ); + options.onWarning?.(warning); + return { mutation: null, warning }; + } + + const warning = physicalBulkWarning(command.decision); + options.onWarning?.(warning); + const previousConfiguration = await getCurrentConfiguration(); + await configurePermissions({ + permissionPromptPolicy: getPolicyForDecision(command.decision), + }); + + if (options.getClient == null) { + return { mutation: null, warning }; + } + + const id = `ios-physical-permissions-${++mutationCounter}`; + const mutation: IosPermissionMutation = { + id, + snapshot: { + kind: 'physical', + configuration: previousConfiguration, + }, + revert: async () => { + await controller.permissions.revert(id); + }, + }; + + outstandingMutations.set(id, mutation); + return { mutation, warning }; + }, + revert: async (mutationId) => { + const mutation = outstandingMutations.get(mutationId); + + if (!mutation || mutation.snapshot.kind !== 'physical') { + return; + } + + outstandingMutations.delete(mutationId); + await configurePermissions(mutation.snapshot.configuration); + }, + resetOutstanding: async () => { + const mutations = [...outstandingMutations.values()].reverse(); + outstandingMutations.clear(); + + for (const mutation of mutations) { + if (mutation.snapshot.kind === 'physical') { + await configurePermissions(mutation.snapshot.configuration); + } + } + }, + }, + }; + + return controller; +}; diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 36c62cd1..7d99c5fb 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -26,9 +26,16 @@ import { import { HarnessAppPathError } from './errors.js'; import { logger } from '@react-native-harness/tools'; import fs from 'node:fs'; -import { createXCTestAgentController } from './xctest-agent.js'; -import { createPermissionPromptAutoAcceptCapability } from './xctest-agent-capabilities.js'; +import { createXCTestAgentController, type XCTestAgentController } from './xctest-agent.js'; import { collectNativeCoverage, cleanProfrawDir } from './coverage-collector.js'; +import { + createIosPhysicalDeviceState, + createIosSimulatorDeviceState, +} from './device-state.js'; +import { createXCTestAgentClient } from './xctest-agent-client.js'; +import { createDeviceXCTestAgentTransport } from './xctest-agent-transport-device.js'; + +const XCTEST_AGENT_PORT = 49200; const iosInstanceLogger = logger.child('ios-instance'); @@ -129,32 +136,21 @@ export const getAppleSimulatorPlatformInstance = async ( `localhost:${harnessConfig.metroPort}`, ); - const xctestAgent = permissionsEnabled - ? createXCTestAgentController({ - appBundleId: config.bundleId, - target: { - kind: 'simulator', - id: udid, - }, - capabilities: [createPermissionPromptAutoAcceptCapability()], - }) - : null; - - let agentStarted = false; - try { - await xctestAgent?.ensureStarted(); - agentStarted = true; - } finally { - if (!agentStarted) { - await xctestAgent?.dispose(); - await simctl.clearHarnessJsLocationOverride(udid, config.bundleId); - if (startedByHarness) { - await simctl.shutdownSimulator(udid); - } - } + const deviceState = createIosSimulatorDeviceState({ + udid, + bundleId: config.bundleId, + onWarning: (warning) => logger.warn(warning), + }); + + if (permissionsEnabled) { + await deviceState.permissions.apply({ + kind: 'permission-all', + decision: 'grant', + }); } return { + deviceState, startApp: async (options) => { await simctl.startApp( udid, @@ -176,7 +172,6 @@ export const getAppleSimulatorPlatformInstance = async ( await simctl.stopApp(udid, config.bundleId); }, dispose: async () => { - await xctestAgent?.dispose(); await simctl.stopApp(udid, config.bundleId); await simctl.clearHarnessJsLocationOverride(udid, config.bundleId); @@ -215,6 +210,7 @@ export const getApplePhysicalDevicePlatformInstance = async ( harnessConfig: HarnessConfig, ): Promise => { assertAppleDevicePhysical(config.device); + const deviceConfig = config.device; const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true; const permissionsEnabled = harnessConfig.permissions ?? false; @@ -241,35 +237,58 @@ export const getApplePhysicalDevicePlatformInstance = async ( ); } - const xctestAgent = permissionsEnabled && config.device.codeSign - ? createXCTestAgentController({ + if (permissionsEnabled && !deviceConfig.codeSign) { + iosInstanceLogger.info( + 'Skipping XCTest agent for physical device (no codeSign config provided)', + ); + } + + let xctestAgent: XCTestAgentController | null = null; + let xctestAgentClient: ReturnType | null = null; + const ensureXCTestAgentClient = async () => { + if (!deviceConfig.codeSign) { + return null; + } + + if (!xctestAgent) { + xctestAgent = createXCTestAgentController({ appBundleId: config.bundleId, target: { kind: 'device', id: device.hardwareProperties.udid, - codeSign: config.device.codeSign, + codeSign: deviceConfig.codeSign, }, - capabilities: [createPermissionPromptAutoAcceptCapability()], - }) - : null; - - if (xctestAgent) { - let agentStarted = false; - try { - await xctestAgent.ensureStarted(); - agentStarted = true; - } finally { - if (!agentStarted) { - await xctestAgent.dispose(); - } + port: XCTEST_AGENT_PORT, + }); } - } else if (permissionsEnabled) { - iosInstanceLogger.info( - 'Skipping XCTest agent for physical device (no codeSign config provided)', - ); + + await xctestAgent.ensureStarted(); + + if (!xctestAgentClient) { + xctestAgentClient = createXCTestAgentClient( + createDeviceXCTestAgentTransport({ + deviceId, + port: XCTEST_AGENT_PORT, + }), + ); + } + + return xctestAgentClient; + }; + const deviceState = createIosPhysicalDeviceState({ + getClient: deviceConfig.codeSign ? ensureXCTestAgentClient : undefined, + onWarning: (warning) => logger.warn(warning), + }); + + if (permissionsEnabled) { + await deviceState.permissions.apply({ + kind: 'permission-all', + decision: 'grant', + }); } return { + deviceState, startApp: async (options) => { await devicectl.startApp( deviceId, @@ -291,6 +310,7 @@ export const getApplePhysicalDevicePlatformInstance = async ( await devicectl.stopApp(deviceId, config.bundleId); }, dispose: async () => { + await xctestAgentClient?.dispose(); await xctestAgent?.dispose(); await devicectl.stopApp(deviceId, config.bundleId); }, diff --git a/packages/platform-ios/src/xcrun/simctl.ts b/packages/platform-ios/src/xcrun/simctl.ts index f5fdaa37..e1ee9bb1 100644 --- a/packages/platform-ios/src/xcrun/simctl.ts +++ b/packages/platform-ios/src/xcrun/simctl.ts @@ -359,6 +359,22 @@ export const shutdownSimulator = async (udid: string): Promise => { await spawnAndForget('xcrun', ['simctl', 'shutdown', udid]); }; +export const setPrivacyPermission = async (options: { + udid: string; + bundleId: string; + service: string; + decision: 'grant' | 'deny' | 'reset'; +}): Promise => { + await spawn('xcrun', [ + 'simctl', + 'privacy', + options.udid, + options.decision === 'deny' ? 'revoke' : options.decision, + options.service, + options.bundleId, + ]); +}; + export const installApp = async ( udid: string, appPath: string, diff --git a/packages/platform-ios/src/xctest-agent-capabilities.ts b/packages/platform-ios/src/xctest-agent-capabilities.ts index 38e270c1..f267b022 100644 --- a/packages/platform-ios/src/xctest-agent-capabilities.ts +++ b/packages/platform-ios/src/xctest-agent-capabilities.ts @@ -1,20 +1,20 @@ import type { XCTestAgentCapability } from './xctest-agent.js'; -const ENABLE_PERMISSION_PROMPT_AUTO_ACCEPT = - 'HARNESS_XCTEST_AGENT_AUTO_ACCEPT_PERMISSIONS'; +const PERMISSION_PROMPT_POLICY = 'HARNESS_XCTEST_AGENT_PERMISSION_PROMPT_POLICY'; -export const createPermissionPromptAutoAcceptCapability = - (): XCTestAgentCapability => { - return { - getLaunchEnvironment: () => ({ - [ENABLE_PERMISSION_PROMPT_AUTO_ACCEPT]: '1', - }), - updateConfiguration: (configuration) => ({ - ...configuration, - permissions: { - ...configuration.permissions, - autoAcceptPermissions: true, - }, - }), - }; +export const createPermissionPromptCapability = ( + permissionPromptPolicy: 'grant-all' | 'deny-all' | 'disabled', +): XCTestAgentCapability => { + return { + getLaunchEnvironment: () => ({ + [PERMISSION_PROMPT_POLICY]: permissionPromptPolicy, + }), + updateConfiguration: (configuration) => ({ + ...configuration, + permissions: { + ...configuration.permissions, + permissionPromptPolicy, + }, + }), }; +}; diff --git a/packages/platform-ios/src/xctest-agent-client.ts b/packages/platform-ios/src/xctest-agent-client.ts index 93f28b5c..c43d36b8 100644 --- a/packages/platform-ios/src/xctest-agent-client.ts +++ b/packages/platform-ios/src/xctest-agent-client.ts @@ -4,7 +4,7 @@ import type { } from './xctest-agent-transport.js'; export type XCTestAgentPermissionsConfiguration = { - autoAcceptPermissions: boolean; + permissionPromptPolicy: 'grant-all' | 'deny-all' | 'disabled'; }; type XCTestAgentHealthResponse = { diff --git a/packages/platform-ios/src/xctest-agent.ts b/packages/platform-ios/src/xctest-agent.ts index 00e3034e..c4c98bfe 100644 --- a/packages/platform-ios/src/xctest-agent.ts +++ b/packages/platform-ios/src/xctest-agent.ts @@ -718,7 +718,7 @@ export const buildXCTestAgent = async ( const getDefaultRuntimeConfiguration = (): XCTestAgentRuntimeConfiguration => { return { permissions: { - autoAcceptPermissions: false, + permissionPromptPolicy: 'disabled', }, }; }; diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/PermissionPromptWatchdog.swift b/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/PermissionPromptWatchdog.swift index 7b0631f8..da9569e3 100644 --- a/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/PermissionPromptWatchdog.swift +++ b/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/PermissionPromptWatchdog.swift @@ -1,15 +1,15 @@ import XCTest private enum PermissionPromptEnvironment { - static let autoAcceptPermissions = "HARNESS_XCTEST_AGENT_AUTO_ACCEPT_PERMISSIONS" + static let permissionPromptPolicy = "HARNESS_XCTEST_AGENT_PERMISSION_PROMPT_POLICY" } struct PermissionPromptConfiguration: Codable { - var autoAcceptPermissions: Bool + var permissionPromptPolicy: String static func fromEnvironment() -> PermissionPromptConfiguration { return PermissionPromptConfiguration( - autoAcceptPermissions: ProcessInfo.processInfo.environment[PermissionPromptEnvironment.autoAcceptPermissions] == "1" + permissionPromptPolicy: ProcessInfo.processInfo.environment[PermissionPromptEnvironment.permissionPromptPolicy] ?? "disabled" ) } } @@ -29,6 +29,15 @@ final class PermissionPromptWatchdog: AgentCapability { "Pair", "Allow Full Access" ] + static let knownNegativeButtonLabels = [ + "Don’t Allow", + "Don't Allow", + "Not Now", + "Cancel", + "Deny", + "No", + "Keep Only While Using" + ] } private let springboard: XCUIApplication @@ -44,17 +53,24 @@ final class PermissionPromptWatchdog: AgentCapability { } func setUp() throws { - if state.permissions.autoAcceptPermissions { + if state.permissions.permissionPromptPolicy != "disabled" { log("permission prompt watchdog enabled") } } func tick() throws { - guard state.permissions.autoAcceptPermissions else { + let labels: [String] + + switch state.permissions.permissionPromptPolicy { + case "grant-all": + labels = Constants.knownPositiveButtonLabels + case "deny-all": + labels = Constants.knownNegativeButtonLabels + default: return } - for label in Constants.knownPositiveButtonLabels { + for label in labels { let button = springboard.buttons[label].firstMatch if button.exists && button.isHittable { diff --git a/packages/platforms/src/device-state.ts b/packages/platforms/src/device-state.ts new file mode 100644 index 00000000..6523f013 --- /dev/null +++ b/packages/platforms/src/device-state.ts @@ -0,0 +1,51 @@ +export type PermissionName = + | 'all' + | 'calendar' + | 'contacts' + | 'contacts-limited' + | 'location' + | 'location-always' + | 'photos' + | 'photos-add' + | 'media-library' + | 'mediaLibrary' + | 'microphone' + | 'motion' + | 'reminders' + | 'siri' + | 'notifications' + | 'camera' + | (string & {}); + +export type PermissionDecision = 'grant' | 'deny' | 'reset'; + +export type DevicePermissionCommand = + | { + kind: 'permission'; + permission: PermissionName; + decision: PermissionDecision; + } + | { + kind: 'permission-all'; + decision: PermissionDecision; + }; + +export type DeviceStateMutation = { + id: string; + revert: () => Promise; +}; + +export type DeviceStateApplyResult = { + mutation: DeviceStateMutation | null; + warning?: string; +}; + +export type DeviceStateController = { + permissions: { + apply: ( + command: DevicePermissionCommand, + ) => Promise; + revert: (mutationId: string) => Promise; + resetOutstanding: () => Promise; + }; +}; diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index 043a4f30..06892d4c 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -1,3 +1,11 @@ +export type { + DeviceStateApplyResult, + DevicePermissionCommand, + DeviceStateController, + DeviceStateMutation, + PermissionDecision, + PermissionName, +} from './device-state.js'; export type { HarnessCliCommand, HarnessCliCommandContext, diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index f4d16819..2aacf26e 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -1,3 +1,5 @@ +import type { DeviceStateController } from './device-state.js'; + export type AppCrashDetails = { source?: 'polling' | 'logs' | 'bridge'; summary?: string; @@ -109,6 +111,7 @@ export type HarnessPlatformRunner = { stopApp: () => Promise; dispose: () => Promise; isAppRunning: () => Promise; + deviceState?: DeviceStateController; createAppMonitor: (options?: CreateAppMonitorOptions) => AppMonitor; getCrashDetails?: ( options: CrashDetailsLookupOptions, diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 4a1bf285..62e952ee 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "@react-native-harness/bridge": "workspace:*", + "@react-native-harness/platforms": "workspace:*", "@vitest/expect": "4.0.16", "@vitest/spy": "4.0.16", "chai": "^6.2.2", diff --git a/packages/runtime/src/__tests__/device.test.ts b/packages/runtime/src/__tests__/device.test.ts new file mode 100644 index 00000000..28cb0ec0 --- /dev/null +++ b/packages/runtime/src/__tests__/device.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { device } from '../device/index.js'; +import { setHandle } from '../client/store.js'; + +describe('runtime device permissions API', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('sends named grant commands through the bridge handle', async () => { + const applyDevicePermission = vi.fn(async () => ({ mutationId: 'mutation-1' })); + const revertDevicePermission = vi.fn(async () => undefined); + + setHandle({ + reportReady: vi.fn(), + emitEvent: vi.fn(), + applyDevicePermission, + revertDevicePermission, + transferScreenshot: vi.fn(), + matchImageSnapshot: vi.fn(), + disconnect: vi.fn(), + }); + + const mutation = await device.permissions.grant('microphone'); + + expect(applyDevicePermission).toHaveBeenCalledWith({ + kind: 'permission', + permission: 'microphone', + decision: 'grant', + }); + expect(mutation?.id).toBe('mutation-1'); + + await mutation?.revert(); + + expect(revertDevicePermission).toHaveBeenCalledWith('mutation-1'); + }); + + it('sends bulk commands through the bridge handle', async () => { + const applyDevicePermission = vi.fn(async () => ({})); + + setHandle({ + reportReady: vi.fn(), + emitEvent: vi.fn(), + applyDevicePermission, + revertDevicePermission: vi.fn(), + transferScreenshot: vi.fn(), + matchImageSnapshot: vi.fn(), + disconnect: vi.fn(), + }); + + const mutation = await device.permissions.denyAll(); + + expect(applyDevicePermission).toHaveBeenCalledWith({ + kind: 'permission-all', + decision: 'deny', + }); + expect(mutation).toBeNull(); + }); + + it('warns and returns null when the host reports a no-op warning', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + setHandle({ + reportReady: vi.fn(), + emitEvent: vi.fn(), + applyDevicePermission: vi.fn(async () => ({ warning: 'not supported' })), + revertDevicePermission: vi.fn(), + transferScreenshot: vi.fn(), + matchImageSnapshot: vi.fn(), + disconnect: vi.fn(), + }); + + await expect(device.permissions.reset('camera')).resolves.toBeNull(); + expect(warn).toHaveBeenCalledWith('not supported'); + }); +}); diff --git a/packages/runtime/src/device/index.ts b/packages/runtime/src/device/index.ts new file mode 100644 index 00000000..d8e17d44 --- /dev/null +++ b/packages/runtime/src/device/index.ts @@ -0,0 +1,74 @@ +import type { + DevicePermissionCommand, + DeviceStateMutation, + PermissionName, +} from '@react-native-harness/platforms'; +import { getHandle } from '../client/store.js'; + +const warnIfNeeded = (warning?: string): void => { + if (warning) { + console.warn(warning); + } +}; + +const applyPermissionCommand = async ( + command: DevicePermissionCommand, +): Promise => { + const result = await getHandle().applyDevicePermission(command); + warnIfNeeded(result.warning); + + if (!result.mutationId) { + return null; + } + + return { + id: result.mutationId, + revert: async () => { + await getHandle().revertDevicePermission(result.mutationId as string); + }, + }; +}; + +export const device = { + permissions: { + grant: async (permission: PermissionName): Promise => { + return await applyPermissionCommand({ + kind: 'permission', + permission, + decision: 'grant', + }); + }, + revoke: async (permission: PermissionName): Promise => { + return await applyPermissionCommand({ + kind: 'permission', + permission, + decision: 'deny', + }); + }, + reset: async (permission: PermissionName): Promise => { + return await applyPermissionCommand({ + kind: 'permission', + permission, + decision: 'reset', + }); + }, + grantAll: async (): Promise => { + return await applyPermissionCommand({ + kind: 'permission-all', + decision: 'grant', + }); + }, + denyAll: async (): Promise => { + return await applyPermissionCommand({ + kind: 'permission-all', + decision: 'deny', + }); + }, + resetAll: async (): Promise => { + return await applyPermissionCommand({ + kind: 'permission-all', + decision: 'reset', + }); + }, + }, +}; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index deac01f1..412ab33f 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -6,6 +6,7 @@ export { UI as ReactNativeHarness } from './ui/index.js'; export * from './spy/index.js'; export * from './expect/index.js'; export * from './collector/index.js'; +export * from './device/index.js'; export * from './mocker/index.js'; export * from './namespace.js'; export * from './waitFor.js'; diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json index b539454e..03392747 100644 --- a/packages/runtime/tsconfig.json +++ b/packages/runtime/tsconfig.json @@ -3,6 +3,9 @@ "files": [], "include": [], "references": [ + { + "path": "../platforms" + }, { "path": "../bridge" }, diff --git a/packages/runtime/tsconfig.lib.json b/packages/runtime/tsconfig.lib.json index f8afc106..46b44f62 100644 --- a/packages/runtime/tsconfig.lib.json +++ b/packages/runtime/tsconfig.lib.json @@ -13,6 +13,9 @@ }, "include": ["src/**/*.ts", "src/**/*.tsx"], "references": [ + { + "path": "../platforms/tsconfig.lib.json" + }, { "path": "../bridge/tsconfig.lib.json" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50b36526..0380e21c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -549,6 +549,9 @@ importers: '@react-native-harness/bridge': specifier: workspace:* version: link:../bridge + '@react-native-harness/platforms': + specifier: workspace:* + version: link:../platforms '@vitest/expect': specifier: 4.0.16 version: 4.0.16