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
118 changes: 111 additions & 7 deletions src/core/__tests__/dispatch-resolve.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { beforeEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';

const { mockFindBootableIosSimulator, mockListAppleDevices, mockListAndroidDevices } = vi.hoisted(
() => ({
mockFindBootableIosSimulator: vi.fn(),
mockListAppleDevices: vi.fn(),
mockListAndroidDevices: vi.fn(),
}),
);
const {
mockFindBootableIosSimulator,
mockFindIosSimulatorInstalledApp,
mockListAppleDevices,
mockListAndroidDevices,
} = vi.hoisted(() => ({
mockFindBootableIosSimulator: vi.fn(),
mockFindIosSimulatorInstalledApp: vi.fn(),
mockListAppleDevices: vi.fn(),
mockListAndroidDevices: vi.fn(),
}));

vi.mock('../../platforms/apple/core/devices.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/apple/core/devices.ts')>();
Expand All @@ -26,6 +30,14 @@ vi.mock('../../platforms/android/devices.ts', async (importOriginal) => {
};
});

vi.mock('../../platforms/apple/core/apps.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/apple/core/apps.ts')>();
return {
...actual,
findIosSimulatorInstalledApp: mockFindIosSimulatorInstalledApp,
};
});

import {
resolveTargetDevice,
withDeviceInventoryProvider,
Expand Down Expand Up @@ -61,6 +73,15 @@ const bootedSimulator: DeviceInfo = {
booted: true,
};

const secondBootedSimulator: DeviceInfo = {
platform: 'apple',
id: 'sim-3',
name: 'iPhone 16',
kind: 'simulator',
target: 'mobile',
booted: true,
};

const webDesktop: DeviceInfo = {
platform: 'web',
id: 'agent-browser-chrome',
Expand All @@ -82,6 +103,8 @@ const androidEmulator: DeviceInfo = {
beforeEach(() => {
mockFindBootableIosSimulator.mockReset();
mockFindBootableIosSimulator.mockResolvedValue(null);
mockFindIosSimulatorInstalledApp.mockReset();
mockFindIosSimulatorInstalledApp.mockResolvedValue(undefined);
mockListAppleDevices.mockReset();
mockListAndroidDevices.mockReset();
});
Expand Down Expand Up @@ -141,6 +164,87 @@ test('resolveTargetDevice request cache key separates device selectors', async (
assert.equal(mockListAppleDevices.mock.calls.length, 2);
});

test('resolveTargetDevice selects the unique booted simulator with the requested app', async () => {
mockListAppleDevices.mockResolvedValue([bootedSimulator, secondBootedSimulator]);
mockFindIosSimulatorInstalledApp.mockImplementation(async (device) =>
device.id === secondBootedSimulator.id ? 'com.example.demo' : undefined,
);

const result = await resolveTargetDevice(
{ platform: 'ios' },
{ appleSimulatorAppTarget: 'com.example.demo' },
);

assert.equal(result.id, secondBootedSimulator.id);
assert.deepEqual(
mockFindIosSimulatorInstalledApp.mock.calls.map(([device, app]) => [device.id, app]),
[
[bootedSimulator.id, 'com.example.demo'],
[secondBootedSimulator.id, 'com.example.demo'],
],
);
});

test('resolveTargetDevice reuses an app-aware selection for later request resolution', async () => {
mockListAppleDevices.mockResolvedValue([bootedSimulator, secondBootedSimulator]);
mockFindIosSimulatorInstalledApp.mockImplementation(async (device) =>
device.id === secondBootedSimulator.id ? 'com.example.demo' : undefined,
);

const [appAware, laterResolution] = await withResolveTargetDeviceCacheScope(async () => [
await resolveTargetDevice({ platform: 'ios' }, { appleSimulatorAppTarget: 'com.example.demo' }),
await resolveTargetDevice({ platform: 'ios' }),
]);

assert.equal(appAware.id, secondBootedSimulator.id);
assert.equal(laterResolution.id, secondBootedSimulator.id);
assert.equal(mockListAppleDevices.mock.calls.length, 1);
});

test('resolveTargetDevice refuses booted simulator selection when the requested app is absent', async () => {
mockListAppleDevices.mockResolvedValue([bootedSimulator, secondBootedSimulator]);

const error = await resolveTargetDevice(
{ platform: 'ios' },
{ appleSimulatorAppTarget: 'com.example.demo' },
).catch((cause) => cause);

assert.ok(error instanceof AppError);
assert.equal(error.code, 'APP_NOT_INSTALLED');
assert.match(error.message, /No booted iOS simulator has com\.example\.demo installed/);
assert.deepEqual(error.details?.candidates, [
{ id: bootedSimulator.id, name: bootedSimulator.name },
{ id: secondBootedSimulator.id, name: secondBootedSimulator.name },
]);
});

test('resolveTargetDevice refuses ambiguous booted simulator app matches', async () => {
mockListAppleDevices.mockResolvedValue([bootedSimulator, secondBootedSimulator]);
mockFindIosSimulatorInstalledApp.mockResolvedValue('com.example.demo');

const error = await resolveTargetDevice(
{ platform: 'ios' },
{ appleSimulatorAppTarget: 'com.example.demo' },
).catch((cause) => cause);

assert.ok(error instanceof AppError);
assert.equal(error.code, 'AMBIGUOUS_MATCH');
assert.match(error.message, /Multiple booted iOS simulators have com\.example\.demo installed/);
assert.equal(error.details?.hint, 'Pass --udid to select the intended simulator explicitly.');
});

test('resolveTargetDevice does not probe when an Apple device is explicitly selected', async () => {
mockListAppleDevices.mockResolvedValue([bootedSimulator, secondBootedSimulator]);

const result = await resolveTargetDevice(
{ platform: 'ios', udid: bootedSimulator.id },
{ appleSimulatorAppTarget: 'com.example.demo' },
);

assert.equal(result.id, bootedSimulator.id);
assert.equal(mockFindIosSimulatorInstalledApp.mock.calls.length, 0);
});

test('resolveTargetDevice does not reuse cache across request scopes', async () => {
mockListAppleDevices.mockResolvedValue([bootedSimulator]);

Expand Down
77 changes: 74 additions & 3 deletions src/core/dispatch-resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { AsyncLocalStorage } from 'node:async_hooks';
import { AppError } from '../kernel/errors.ts';
import {
isApplePlatform,
isIosFamily,
matchesDeviceSelector,
resolveDevice,
resolveAppleSimulatorSetPathForSelector,
type DeviceInfo,
Expand Down Expand Up @@ -49,8 +51,9 @@ type AppleDeviceSelector = {
serial?: string;
};

type ResolveTargetDeviceOptions = {
export type ResolveTargetDeviceOptions = {
allowStoppedAndroidAvdPlaceholders?: boolean;
appleSimulatorAppTarget?: string;
};

/**
Expand All @@ -63,8 +66,15 @@ type ResolveTargetDeviceOptions = {
async function resolveAppleDevice(
devices: DeviceInfo[],
selector: AppleDeviceSelector,
context: { simulatorSetPath?: string; allowLocalSimulatorFallback?: boolean },
context: {
simulatorSetPath?: string;
allowLocalSimulatorFallback?: boolean;
appleSimulatorAppTarget?: string;
},
): Promise<DeviceInfo> {
const appMatchedSimulator = await findBootedAppleSimulatorWithApp(devices, selector, context);
if (appMatchedSimulator) return appMatchedSimulator;

const selected = await resolveAppleDeviceCandidate(devices, selector, context);

if (
Expand All @@ -83,6 +93,61 @@ async function resolveAppleDevice(
throw new AppError('DEVICE_NOT_FOUND', 'No devices found', { selector });
}

async function findBootedAppleSimulatorWithApp(
devices: DeviceInfo[],
selector: AppleDeviceSelector,
context: {
allowLocalSimulatorFallback?: boolean;
appleSimulatorAppTarget?: string;
},
): Promise<DeviceInfo | undefined> {
const appTarget = context.appleSimulatorAppTarget?.trim();
if (!appTarget || hasExplicitAppleDeviceSelector(selector)) return undefined;
if (context.allowLocalSimulatorFallback === false) return undefined;

const bootedSimulators = devices.filter(
(device) =>
matchesDeviceSelector(device, selector) &&
isIosFamily(device) &&
device.kind === 'simulator' &&
device.booted === true,
);
if (bootedSimulators.length < 2) return undefined;

const { findIosSimulatorInstalledApp } = await import('../platforms/apple/core/apps.ts');
const matches = (
await Promise.all(
bootedSimulators.map(async (device) =>
(await findIosSimulatorInstalledApp(device, appTarget)) ? device : undefined,
),
)
).filter((device): device is DeviceInfo => device !== undefined);

if (matches.length === 1) return matches[0];

const candidates = bootedSimulators.map((device) => ({
id: device.id,
name: device.name,
}));
if (matches.length === 0) {
throw new AppError('APP_NOT_INSTALLED', `No booted iOS simulator has ${appTarget} installed`, {
appTarget,
candidates,
hint: 'Install the app on a booted simulator, or pass --udid to select the intended device explicitly.',
});
}

throw new AppError(
'AMBIGUOUS_MATCH',
`Multiple booted iOS simulators have ${appTarget} installed`,
{
appTarget,
candidates: matches.map((device) => ({ id: device.id, name: device.name })),
hint: 'Pass --udid to select the intended simulator explicitly.',
},
);
}

async function resolveAppleDeviceCandidate(
devices: DeviceInfo[],
selector: AppleDeviceSelector,
Expand Down Expand Up @@ -155,6 +220,7 @@ export async function resolveTargetDevice(
await resolveAppleDevice(injectedDevices, selector as AppleDeviceSelector, {
simulatorSetPath: iosSimulatorSetPath,
allowLocalSimulatorFallback: inventoryRequest.leaseProvider === undefined,
appleSimulatorAppTarget: options.appleSimulatorAppTarget,
}),
);
}
Expand All @@ -171,6 +237,7 @@ export async function resolveTargetDevice(
cacheKey,
await resolveAppleDevice(devices, selector as AppleDeviceSelector, {
simulatorSetPath: iosSimulatorSetPath,
appleSimulatorAppTarget: options.appleSimulatorAppTarget,
}),
);
}
Expand Down Expand Up @@ -277,5 +344,9 @@ function buildResolveTargetDeviceCacheKey(
request: DeviceInventoryRequest,
options: ResolveTargetDeviceOptions,
): string {
return JSON.stringify({ request, options });
// The app target only informs the first device choice. Once a request has
// chosen a device, every later resolution must reuse that same device even
// when dispatch has no app target to pass back through this seam.
const { appleSimulatorAppTarget: _appleSimulatorAppTarget, ...cacheOptions } = options;
return JSON.stringify({ request, options: cacheOptions });
}
25 changes: 24 additions & 1 deletion src/daemon/__tests__/request-router-open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,13 @@ function openRequest(
flags: Record<string, unknown>,
requestId: string,
meta: Record<string, unknown> = {},
positionals: string[] = [],
) {
return {
token: 'test-token',
session,
command: 'open',
positionals: [],
positionals,
flags,
meta: { requestId, ...meta },
};
Expand Down Expand Up @@ -99,6 +100,28 @@ test('open returns and creates the session state directory', async () => {
}
});

test('fresh open uses app-aware device selection for advisory locking and dispatch', async () => {
const sessionStore = makeSessionStore('agent-device-router-open-');
const genericDevice = makeIosDevice('SIM-GENERIC');
const appDevice = makeIosDevice('SIM-WITH-APP');
mockResolveTargetDevice.mockImplementation(async (_flags, options) =>
options?.appleSimulatorAppTarget === 'com.example.demo' ? appDevice : genericDevice,
);

const response = await createOpenHandler(sessionStore)(
openRequest('session-app-aware', { platform: 'ios' }, 'req-open-app-aware', {}, [
'com.example.demo',
]),
);

expect(response.ok).toBe(true);
expect(mockResolveTargetDevice.mock.calls).toEqual([
[{ platform: 'ios' }, { appleSimulatorAppTarget: 'com.example.demo' }],
[{ platform: 'ios' }, { appleSimulatorAppTarget: 'com.example.demo' }],
]);
expect(sessionStore.get('session-app-aware')?.device.id).toBe(appDevice.id);
});

test('open --debug writes bounded open timing diagnostics to requestLogPath', async () => {
const sessionStore = makeSessionStore('agent-device-router-open-');
const device = makeIosDevice('SIM-DEBUG');
Expand Down
4 changes: 4 additions & 0 deletions src/daemon/handlers/__tests__/session-open-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ test('open applies stored runtime launchUrl and reports runtime hints', async ()
});

expect(response?.ok).toBe(true);
expect(mockResolveTargetDevice).toHaveBeenCalledWith(
{ platform: 'android' },
{ appleSimulatorAppTarget: 'Demo' },
);
expect(callOrder).toEqual(['runtime', 'dispatch:open', 'dispatch:open']);
expect(runtimeApplyCalls).toEqual([{ appId: 'com.example.demo', host: '10.0.0.10', port: 8081 }]);
expect(dispatchCalls).toEqual([
Expand Down
6 changes: 5 additions & 1 deletion src/daemon/handlers/session-open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { withKeyedLock } from '../../utils/keyed-lock.ts';
import { emitDiagnostic, getDiagnosticsMeta } from '../../utils/diagnostics.ts';
import { isActiveProviderDevice } from '../../provider-device-runtime.ts';
import { inferAndroidPackageAfterOpen } from './session-open-target.ts';
import { buildOpenTargetDeviceResolutionOptions } from '../open-device-selection.ts';
import {
invalidOpenArgs,
prepareOpenCommandDetails,
Expand Down Expand Up @@ -757,7 +758,10 @@ export async function handleOpenCommand(params: {
return preResolvedValidation;
}

const device = await resolveTargetDevice(req.flags ?? {});
const device = await resolveTargetDevice(
req.flags ?? {},
buildOpenTargetDeviceResolutionOptions(openTarget),
);
const surfaceResult = resolveOpenSurfaceResponse(device, req.flags?.surface, openTarget);
if (typeof surfaceResult !== 'string') {
return surfaceResult;
Expand Down
10 changes: 10 additions & 0 deletions src/daemon/open-device-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { isDeepLinkTarget } from '../contracts/open-target.ts';
import type { ResolveTargetDeviceOptions } from '../core/dispatch-resolve.ts';

export function buildOpenTargetDeviceResolutionOptions(
openTarget: string | undefined,
): ResolveTargetDeviceOptions {
return {
appleSimulatorAppTarget: openTarget && !isDeepLinkTarget(openTarget) ? openTarget : undefined,
};
}
6 changes: 5 additions & 1 deletion src/daemon/request-binding.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { resolveTargetDevice } from '../core/dispatch-resolve.ts';
import { hasExplicitDeviceSelector } from './device-selector-intent.ts';
import { applyRequestLockPolicy } from './request-lock-policy.ts';
import { buildOpenTargetDeviceResolutionOptions } from './open-device-selection.ts';
import type { SessionStore } from './session-store.ts';
import type { DaemonRequest, SessionState } from './types.ts';

Expand Down Expand Up @@ -28,7 +29,10 @@ export async function resolveRequestExecutionLockKeys(params: {
try {
// This is advisory lock selection before the request enters the lock; the
// locked request still resolves and binds the target device authoritatively.
const device = await resolveTargetDevice(bindingReq.flags ?? {});
const device = await resolveTargetDevice(
bindingReq.flags ?? {},
buildOpenTargetDeviceResolutionOptions(bindingReq.positionals?.[0]),
);
keys.add(deviceExecutionLockKey(device.id));
} catch {
// Fall back to session scoping when device resolution is not yet available.
Expand Down
Loading
Loading