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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@
"check:unit": "pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke",
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
"prepack": "pnpm check:mcp-metadata && pnpm package:npm",
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/host-kit packages/capture-kit packages/managed-allocation packages/provision-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/command-registry packages/session-journal packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/device-selection packages/host-kit packages/capture-kit packages/managed-allocation packages/provision-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/command-registry packages/session-journal packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
"test-app:install": "pnpm install --dir examples/test-app",
"test-app:start": "pnpm --dir examples/test-app start",
"test-app:ios": "pnpm --dir examples/test-app ios",
Expand Down Expand Up @@ -279,6 +279,7 @@
"@agent-device/capture-kit": "workspace:*",
"@agent-device/command-registry": "workspace:*",
"@agent-device/contracts": "workspace:*",
"@agent-device/device-selection": "workspace:*",
"@agent-device/host-kit": "workspace:*",
"@agent-device/kernel": "workspace:*",
"@agent-device/managed-allocation": "workspace:*",
Expand Down
11 changes: 11 additions & 0 deletions packages/contracts/src/platform-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,20 @@ export type ProviderAwareDeviceInventoryGateway = DeviceInventoryGateway &
): Promise<DeviceInventoryDiscovery>;
}>;

export type InstalledAppProbe = (
device: DeviceInfo,
appTarget: string,
) => Promise<string | undefined>;

export type ComposedDeviceInventoryGateways = Readonly<{
providerFirst: ProviderAwareDeviceInventoryGateway;
localOnly: DeviceInventoryGateway;
/**
* Optional installed-app probe that device selection uses to narrow several
* booted simulators before committing to one. Absent where the host does not
* provide one; selection then falls back to the ordinary inventory rules.
*/
findInstalledApp?: InstalledAppProbe;
}>;

/** A family module gains this interface only with an honest package-owned source. */
Expand Down
27 changes: 27 additions & 0 deletions packages/device-selection/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@agent-device/device-selection",
"version": "0.0.0",
"private": true,
"sideEffects": false,
"type": "module",
"description": "Device selection for dispatched requests: flag-to-inventory request construction, the request-scoped device resolution cache, inventory-backed selection with installed-app narrowing, and the request-scoped device inventory context.",
"dependencies": {
"@agent-device/contracts": "workspace:*",
"@agent-device/host-kit": "workspace:*",
"@agent-device/kernel": "workspace:*"
},
"exports": {
"./dispatch-resolve": {
"types": "./src/dispatch-resolve.ts",
"default": "./src/dispatch-resolve.ts"
},
"./device-selection-resolver": {
"types": "./src/device-selection-resolver.ts",
"default": "./src/device-selection-resolver.ts"
},
"./device-inventory-context": {
"types": "./src/device-inventory-context.ts",
"default": "./src/device-inventory-context.ts"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { DeviceInfo } from '@agent-device/kernel/device';

export const STOPPED_ANDROID_EMULATOR: DeviceInfo = {
platform: 'android',
id: 'Pixel_9_Pro_XL',
name: 'Pixel 9 Pro XL',
kind: 'emulator',
target: 'mobile',
booted: false,
};

export const SECOND_BOOTED_ANDROID_EMULATOR: DeviceInfo = {
platform: 'android',
id: 'emulator-5556',
name: 'Pixel 8',
kind: 'emulator',
target: 'mobile',
booted: true,
};

export const ANDROID_EMULATOR: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};

export const IOS_SIMULATOR: DeviceInfo = {
platform: 'apple',
id: 'sim-1',
name: 'iPhone 17 Pro',
kind: 'simulator',
appleOs: 'ios',
booted: true,
};

export const MACOS_DEVICE: DeviceInfo = {
platform: 'apple',
id: 'host-macos-local',
name: 'Mac',
kind: 'device',
target: 'desktop',
appleOs: 'macos',
booted: true,
};
Original file line number Diff line number Diff line change
@@ -1,27 +1,21 @@
import assert from 'node:assert/strict';
import { test, vi } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import {
ANDROID_EMULATOR,
IOS_SIMULATOR,
MACOS_DEVICE,
} from '../../__tests__/test-utils/device-fixtures.ts';
import type { DeviceInfo } from '@agent-device/kernel/device';
import {
markSelectionBootOccurred,
resolveExistingSessionDeviceSelection,
resolveInventoryDeviceSelection,
} from '../device-selection-resolver.ts';
import { withTestDeviceInventory } from './test-utils/device-inventory-gateways.ts';
import {
ANDROID_EMULATOR,
IOS_SIMULATOR,
MACOS_DEVICE,
SECOND_BOOTED_ANDROID_EMULATOR,
STOPPED_ANDROID_EMULATOR,
} from './device-selection-fixtures.ts';

const mockFindIosSimulatorInstalledApp = vi.hoisted(() => vi.fn());

vi.mock('@agent-device/platform-apple/app-resolution', () => ({
findIosSimulatorInstalledApp: mockFindIosSimulatorInstalledApp,
}));

test('explicit identity selector wins before local inference', async () => {
const selection = await resolveInventoryDeviceSelection({
devices: [ANDROID_EMULATOR, SECOND_BOOTED_ANDROID_EMULATOR],
Expand Down Expand Up @@ -59,16 +53,20 @@ test('a single bootable local candidate is selectable without a preliminary devi

test('the booted simulator with the app installed carries its own selected-by reason', async () => {
const secondBootedSimulator = { ...IOS_SIMULATOR, id: 'sim-2', name: 'iPhone 17' };
mockFindIosSimulatorInstalledApp.mockImplementation(async (device: { id: string }) =>
const findInstalledApp = vi.fn(async (device: DeviceInfo) =>
device.id === secondBootedSimulator.id ? 'com.example.demo' : undefined,
);

const selection = await resolveInventoryDeviceSelection({
devices: [IOS_SIMULATOR, secondBootedSimulator],
selector: { platform: 'ios' },
source: 'local',
appleSimulatorAppTarget: 'com.example.demo',
});
const selection = await withTestDeviceInventory(
{ findInstalledApp },
async () =>
await resolveInventoryDeviceSelection({
devices: [IOS_SIMULATOR, secondBootedSimulator],
selector: { platform: 'ios' },
source: 'local',
appleSimulatorAppTarget: 'com.example.demo',
}),
);

assert.equal(selection.device.id, secondBootedSimulator.id);
assert.equal(selection.reason, 'single-app-installed-local');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import { beforeEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';

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

vi.mock('@agent-device/platform-apple/app-resolution', () => {
return {
findIosSimulatorInstalledApp: mockFindIosSimulatorInstalledApp,
};
});
const mockFindIosSimulatorInstalledApp = vi.fn();
const mockListAppleDevices = vi.fn();
import {
resolveTargetDevice as resolveTargetDeviceInContext,
resolveTargetDeviceSelection as resolveTargetDeviceSelectionInContext,
Expand All @@ -19,7 +11,7 @@ import {
import {
withTestDeviceInventory,
withTestDeviceInventoryProvider as withDeviceInventoryProvider,
} from '../../__tests__/test-utils/device-inventory-gateways.ts';
} from './test-utils/device-inventory-gateways.ts';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { DeviceInventoryRequest } from '@agent-device/contracts/device';
import { AppError } from '@agent-device/kernel/errors';
Expand Down Expand Up @@ -98,6 +90,7 @@ async function resolveTargetDevice(
(request.platform === 'apple' && request.target === 'desktop')
? [macDesktop]
: await mockListAppleDevices(request),
findInstalledApp: mockFindIosSimulatorInstalledApp,
},
async () => await resolveTargetDeviceInContext(...args),
);
Expand Down Expand Up @@ -205,7 +198,10 @@ test('app-narrowed selection reports its own typed provenance, not a generic loc
);

const selection = await withTestDeviceInventory(
{ local: async (request) => await mockListAppleDevices(request) },
{
local: async (request) => await mockListAppleDevices(request),
findInstalledApp: mockFindIosSimulatorInstalledApp,
},
async () =>
await resolveTargetDeviceSelectionInContext(
{ platform: 'ios' },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import {
filterDeviceInventoryProjection,
LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS,
projectProviderDeviceInventoryRequest,
type DeviceInventoryProvider,
type DeviceInventoryRequest,
type ProviderDeviceInventorySource,
} from '@agent-device/contracts/device';
import type {
ComposedDeviceInventoryGateways,
DeviceInventoryGateway,
InstalledAppProbe,
ProviderAwareDeviceInventoryGateway,
} from '@agent-device/contracts/platform-module';
import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host';
import {
isApplePlatform,
type DeviceInfo,
type Platform,
type PlatformSelector,
} from '@agent-device/kernel/device';
import { withDeviceInventoryContext } from '../../device-inventory-context.ts';

export type TestDeviceInventoryOptions = Readonly<{
provider?: ProviderDeviceInventorySource;
local?: (request: Readonly<DeviceInventoryRequest>) => Promise<readonly DeviceInfo[]>;
findInstalledApp?: InstalledAppProbe;
}>;

const testRequestScope: PlatformRequestScope = Object.freeze({
signal: new AbortController().signal,
diagnostics: Object.freeze({ emit: () => {} }),
progress: Object.freeze({ report: () => {} }),
});

type LocalDiscover = (request: Readonly<DeviceInventoryRequest>) => Promise<readonly DeviceInfo[]>;

async function discoverLocalFamily(
localDiscover: LocalDiscover,
platform: PlatformSelector,
request: Readonly<DeviceInventoryRequest>,
scope: PlatformRequestScope,
): Promise<DeviceInfo[]> {
scope.signal.throwIfAborted();
const family: Platform = isApplePlatform(platform) ? 'apple' : platform;
const devices = (await localDiscover(request)).filter((device) => device.platform === family);
return filterDeviceInventoryProjection(devices, request);
}

async function discoverLocal(
localDiscover: LocalDiscover,
request: Readonly<DeviceInventoryRequest>,
scope: PlatformRequestScope,
): Promise<DeviceInfo[]> {
scope.signal.throwIfAborted();
if (request.platform) {
return await discoverLocalFamily(localDiscover, request.platform, request, scope);
}
const perFamily = await Promise.all(
LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS.map(async (selector) => {
try {
return await discoverLocalFamily(
localDiscover,
selector,
{ ...request, platform: selector },
scope,
);
} catch {
return [];
}
}),
);
scope.signal.throwIfAborted();
return perFamily.flat();
}

function createTestDeviceInventoryGateways(
options: TestDeviceInventoryOptions = {},
): ComposedDeviceInventoryGateways {
const localDiscover = options.local ?? (async () => [] as readonly DeviceInfo[]);
const localOnly: DeviceInventoryGateway = Object.freeze({
discover: (request, scope) => discoverLocal(localDiscover, request, scope),
});
const provider = options.provider;
const discoverWithSource: ProviderAwareDeviceInventoryGateway['discoverWithSource'] = async (
request,
scope,
) => {
if (provider) {
scope.signal.throwIfAborted();
const result = await provider.discover(
projectProviderDeviceInventoryRequest(request),
scope.signal,
);
scope.signal.throwIfAborted();
if (result.kind === 'inventory') {
return {
devices: filterDeviceInventoryProjection(
result.devices.map((device) => ({ ...device })),
request,
),
source: 'provider',
};
}
}
return { devices: await localOnly.discover(request, scope), source: 'local' };
};
const providerFirst: ProviderAwareDeviceInventoryGateway = Object.freeze({
discover: async (request, scope) => (await discoverWithSource(request, scope)).devices,
discoverWithSource,
});
return Object.freeze({
providerFirst,
localOnly,
findInstalledApp: options.findInstalledApp,
});
}

export async function withTestDeviceInventory<T>(
options: TestDeviceInventoryOptions,
task: () => Promise<T>,
): Promise<T> {
return await withDeviceInventoryContext(
{ ...createTestDeviceInventoryGateways(options), requestScope: testRequestScope },
task,
);
}

/** Test-only bridge for fixtures that still implement the public nullable provider port. */
export async function withTestDeviceInventoryProvider<T>(
provider: DeviceInventoryProvider,
task: () => Promise<T>,
): Promise<T> {
return await withTestDeviceInventory(
{
provider: {
discover: async (request, signal) => {
signal.throwIfAborted();
const devices = await provider(request, signal);
return devices === null || devices === undefined
? { kind: 'declined' }
: { kind: 'inventory', devices };
},
},
},
task,
);
}
Loading
Loading