Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/daemon/__tests__/request-lock-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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<typeof applyRequestLockPolicy>): {
platform: string | undefined;
device: string | undefined;
serial: string | undefined;
} {
return {
platform: req.flags?.platform,
device: req.flags?.device,
serial: req.flags?.serial,
};
}
48 changes: 48 additions & 0 deletions src/daemon/__tests__/request-platform-providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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[] = [];
Expand Down
64 changes: 52 additions & 12 deletions src/daemon/request-lock-policy.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,6 +21,11 @@ const LOCKABLE_SELECTOR_KEYS: Array<keyof CommandFlags> = [
'androidDeviceAllowlist',
];

const SELECTOR_OVERRIDE_LOCK_POLICY_COMMANDS: ReadonlySet<string> = new Set([
PUBLIC_COMMANDS.apps,
PUBLIC_COMMANDS.devices,
]);

export function applyRequestLockPolicy(
req: DaemonRequest,
existingSession?: SessionState,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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<typeof normalizePlatformSelector>,
locked: ReturnType<typeof normalizePlatformSelector>,
Expand Down
6 changes: 5 additions & 1 deletion src/daemon/request-platform-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,11 @@ async function resolveScopedProviderDevice(
req: DaemonRequest,
existingSession: SessionState | undefined,
): Promise<DeviceInfo | undefined> {
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) &&
Expand Down
Loading