diff --git a/src/daemon/__tests__/request-lock-policy.test.ts b/src/daemon/__tests__/request-lock-policy.test.ts index 8b7717aff..9bcbb1f64 100644 --- a/src/daemon/__tests__/request-lock-policy.test.ts +++ b/src/daemon/__tests__/request-lock-policy.test.ts @@ -118,6 +118,103 @@ test('rejects existing-session selector conflicts under request lock policy', () ); }); +test.each([ + { + command: 'apps', + flags: { platform: 'ios', device: 'iPhone 17' }, + expected: { platform: 'ios', device: 'iPhone 17', serial: undefined }, + }, + { + command: 'devices', + flags: { platform: 'android', serial: 'emulator-5554' }, + expected: { platform: 'android', device: undefined, serial: 'emulator-5554' }, + }, +] as const)( + 'allows $command to inspect a different selector under existing-session lock policy', + ({ command, flags, expected }) => { + const req = applyRequestLockPolicy( + { + token: 'token', + session: 'qa-ios', + command, + positionals: [], + flags, + meta: { + lockPolicy: 'reject', + }, + }, + IOS_SESSION, + ); + + assert.deepEqual(selectedFlags(req), expected); + }, +); + +test.each([ + { + command: 'apps', + flags: { device: 'iPhone 17' }, + expected: { platform: undefined, device: 'iPhone 17', serial: undefined }, + }, + { + command: 'devices', + flags: { serial: 'emulator-5554' }, + expected: { platform: undefined, device: undefined, serial: 'emulator-5554' }, + }, +] as const)( + 'allows $command to inspect a fresh selector under session lock policy', + ({ command, flags, expected }) => { + const req = applyRequestLockPolicy({ + token: 'token', + session: 'qa-ios', + command, + positionals: [], + flags, + meta: { + lockPolicy: 'reject', + lockPlatform: 'ios', + }, + }); + + assert.deepEqual(selectedFlags(req), expected); + }, +); + +test('allows inventory commands to use explicit Apple selectors under another lock platform', () => { + const req = applyRequestLockPolicy({ + token: 'token', + session: 'qa-android', + command: 'apps', + positionals: [], + flags: { + udid: 'SIM-001', + }, + meta: { + lockPolicy: 'reject', + lockPlatform: 'android', + }, + }); + + assert.equal(req.flags?.platform, undefined); + assert.equal(req.flags?.udid, 'SIM-001'); +}); + +test('defaults inventory commands without explicit selectors to the lock platform', () => { + const req = applyRequestLockPolicy({ + token: 'token', + session: 'qa-ios', + command: 'apps', + positionals: [], + flags: {}, + meta: { + lockPolicy: 'reject', + lockPlatform: 'ios', + }, + }); + + assert.equal(req.flags?.platform, 'ios'); +}); + test('allows matching redundant selectors for existing sessions', () => { const req = applyRequestLockPolicy( { @@ -256,3 +353,15 @@ test('strips only conflicting selectors for existing sessions', () => { assert.equal(req.flags?.device, 'iPhone 16'); assert.equal(req.flags?.serial, undefined); }); + +function selectedFlags(req: ReturnType): { + platform: string | undefined; + device: string | undefined; + serial: string | undefined; +} { + return { + platform: req.flags?.platform, + device: req.flags?.device, + serial: req.flags?.serial, + }; +} diff --git a/src/daemon/__tests__/request-platform-providers.test.ts b/src/daemon/__tests__/request-platform-providers.test.ts index f872e0eea..3be760e5c 100644 --- a/src/daemon/__tests__/request-platform-providers.test.ts +++ b/src/daemon/__tests__/request-platform-providers.test.ts @@ -6,12 +6,22 @@ import { makeAndroidSession, makeIosSession, } from '../../__tests__/test-utils/index.ts'; +import { withTargetDeviceResolutionScope } from '../../core/dispatch-resolve.ts'; import { createLocalAppleToolProvider, runXcrun } from '../../platforms/ios/tool-provider.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; import { startAppLog } from '../app-log.ts'; import { resolveRecordingProvider } from '../recording-provider.ts'; import { withRequestPlatformProviderScope } from '../request-platform-providers.ts'; import type { DaemonRequest } from '../types.ts'; +const OTHER_IOS_SIMULATOR: DeviceInfo = { + platform: 'ios', + id: 'sim-2', + name: 'iPhone 17', + kind: 'simulator', + booted: true, +}; + test('request platform provider scope exposes Android executor for Android sessions', async () => { const calls: string[][] = []; const response = await withRequestPlatformProviderScope( @@ -179,6 +189,44 @@ test('request platform provider scope applies Apple tool provider only for Apple assert.deepEqual(calls, [['list', 'devices', '-j']]); }); +test('request platform provider scope follows explicit apps selector for existing sessions', async () => { + const seenDevices: string[] = []; + + const result = await withTargetDeviceResolutionScope( + async () => [OTHER_IOS_SIMULATOR], + async () => + await withRequestPlatformProviderScope( + { + req: { + ...request('apps'), + flags: { + platform: 'ios', + device: 'iPhone 17', + }, + }, + existingSession: makeIosSession('default'), + providers: { + appleToolProvider: ({ device, session }) => { + seenDevices.push(`${session?.name}:${device.id}`); + return createLocalAppleToolProvider({ + runCommand: async (cmd, args) => { + throw new Error(`unexpected generic command: ${cmd} ${args.join(' ')}`); + }, + simctl: { + run: async () => ({ exitCode: 0, stdout: 'apps-ok', stderr: '' }), + }, + }); + }, + }, + }, + async () => await runXcrun(['simctl', 'listapps', OTHER_IOS_SIMULATOR.id]), + ), + ); + + assert.equal(result.stdout, 'apps-ok'); + assert.deepEqual(seenDevices, [`default:${OTHER_IOS_SIMULATOR.id}`]); +}); + test('request platform provider scopes stay isolated across concurrent requests', async () => { const androidCalls: string[] = []; const appleCalls: string[] = []; diff --git a/src/daemon/request-lock-policy.ts b/src/daemon/request-lock-policy.ts index 772c68957..85bdc6e23 100644 --- a/src/daemon/request-lock-policy.ts +++ b/src/daemon/request-lock-policy.ts @@ -1,6 +1,7 @@ import { AppError } from '../utils/errors.ts'; import type { CommandFlags } from '../core/dispatch.ts'; import type { SessionState, DaemonRequest } from './types.ts'; +import { PUBLIC_COMMANDS } from '../command-catalog.ts'; import { formatSessionSelectorConflict, listSessionSelectorConflicts, @@ -20,6 +21,11 @@ const LOCKABLE_SELECTOR_KEYS: Array = [ 'androidDeviceAllowlist', ]; +const SELECTOR_OVERRIDE_LOCK_POLICY_COMMANDS: ReadonlySet = new Set([ + PUBLIC_COMMANDS.apps, + PUBLIC_COMMANDS.devices, +]); + export function applyRequestLockPolicy( req: DaemonRequest, existingSession?: SessionState, @@ -30,13 +36,19 @@ export function applyRequestLockPolicy( } const nextFlags: CommandFlags = { ...(req.flags ?? {}) }; - const conflicts = existingSession - ? listSessionSelectorConflicts(existingSession, nextFlags) - : listFreshSessionConflicts(nextFlags, req.meta?.lockPlatform, req.command); + const canOverrideSelector = SELECTOR_OVERRIDE_LOCK_POLICY_COMMANDS.has(req.command); + const conflicts = canOverrideSelector + ? [] + : existingSession + ? listSessionSelectorConflicts(existingSession, nextFlags) + : listFreshSessionConflicts(nextFlags, req.meta?.lockPlatform, req.command); + const lockPlatform = req.meta?.lockPlatform; if (conflicts.length === 0) { - if (!existingSession && req.meta?.lockPlatform && nextFlags.platform === undefined) { - nextFlags.platform = req.meta.lockPlatform; + if ( + shouldApplyLockPlatformDefault(canOverrideSelector, existingSession, nextFlags, lockPlatform) + ) { + nextFlags.platform = lockPlatform; } return { ...req, @@ -45,12 +57,7 @@ export function applyRequestLockPolicy( } if (lockPolicy === 'strip') { - if (existingSession) { - stripSessionConflicts(nextFlags, conflicts); - nextFlags.platform = existingSession.device.platform; - } else { - stripFreshSessionConflicts(nextFlags, req.meta?.lockPlatform); - } + applyStripLockPolicy(nextFlags, conflicts, lockPlatform, existingSession); return { ...req, flags: nextFlags, @@ -64,6 +71,35 @@ export function applyRequestLockPolicy( ); } +function shouldApplyLockPlatformDefault( + canOverrideSelector: boolean, + existingSession: SessionState | undefined, + flags: CommandFlags, + lockPlatform: LockPlatform, +): boolean { + if (!lockPlatform || existingSession || flags.platform !== undefined) { + return false; + } + if (!canOverrideSelector) { + return true; + } + return !LOCKABLE_SELECTOR_KEYS.some((key) => hasSelectorValue(flags[key])); +} + +function applyStripLockPolicy( + flags: CommandFlags, + conflicts: SessionSelectorConflict[], + lockPlatform: LockPlatform, + existingSession: SessionState | undefined, +): void { + if (existingSession) { + stripSessionConflicts(flags, conflicts); + flags.platform = existingSession.device.platform; + return; + } + stripFreshSessionConflicts(flags, lockPlatform); +} + function listFreshSessionConflicts( flags: CommandFlags, lockPlatform: LockPlatform, @@ -83,13 +119,17 @@ function listFreshSessionConflicts( } for (const key of LOCKABLE_SELECTOR_KEYS) { const value = flags[key]; - if (typeof value === 'string' && value.trim().length > 0) { + if (hasSelectorValue(value)) { conflicts.push({ key: key as SessionSelectorConflictKey, value }); } } return conflicts; } +function hasSelectorValue(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + function platformSelectorsConflict( requested: ReturnType, locked: ReturnType, diff --git a/src/daemon/request-platform-providers.ts b/src/daemon/request-platform-providers.ts index 97b0998ed..72737ec93 100644 --- a/src/daemon/request-platform-providers.ts +++ b/src/daemon/request-platform-providers.ts @@ -270,7 +270,11 @@ async function resolveScopedProviderDevice( req: DaemonRequest, existingSession: SessionState | undefined, ): Promise { - if (existingSession) return existingSession.device; + if (existingSession) { + return req.command === PUBLIC_COMMANDS.apps && hasExplicitDeviceSelector(req.flags) + ? await resolveTargetDevice(req.flags ?? {}) + : existingSession.device; + } if ( req.command !== PUBLIC_COMMANDS.open && !hasExplicitDeviceSelector(req.flags) &&