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
9 changes: 9 additions & 0 deletions src/__tests__/test-utils/device-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ export const LINUX_DEVICE: DeviceInfo = {
target: 'desktop',
};

export const WEB_DESKTOP_DEVICE: DeviceInfo = {
platform: 'web',
id: 'agent-browser-chrome',
name: 'Agent Browser Chrome',
kind: 'device',
target: 'desktop',
booted: true,
};

export const ANDROID_TV_DEVICE: DeviceInfo = {
platform: 'android',
id: 'and-tv-1',
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/test-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
LINUX_DEVICE,
MACOS_DEVICE,
TVOS_SIMULATOR,
WEB_DESKTOP_DEVICE,
} from './device-fixtures.ts';

export {
Expand Down
3 changes: 2 additions & 1 deletion src/client-normalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,8 @@ export function normalizeOpenDevice(
(platform !== 'ios' &&
platform !== 'macos' &&
platform !== 'android' &&
platform !== 'linux') ||
platform !== 'linux' &&
platform !== 'web') ||
!id ||
!name
) {
Expand Down
16 changes: 15 additions & 1 deletion src/commands/__tests__/command-surface-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { listMcpExposedCommandNames } from '../../command-catalog.ts';
import { listCommandMetadataNames, listMcpCommandMetadata } from '../command-metadata.ts';
import {
listCommandMetadata,
listCommandMetadataNames,
listMcpCommandMetadata,
} from '../command-metadata.ts';
import { listExecutableCommandNames } from '../command-surface.ts';

test('MCP exposed command names have metadata and executable command definitions', () => {
Expand All @@ -23,3 +27,13 @@ test('MCP exposed command names have metadata and executable command definitions
test('CI-only prepare command stays out of MCP tool surface', () => {
assert.equal(listMcpExposedCommandNames().includes('prepare'), false);
});

test('common command input accepts web platform selector', () => {
const snapshotMetadata = listCommandMetadata().find((metadata) => metadata.name === 'snapshot');
if (!snapshotMetadata) throw new Error('Expected snapshot command metadata');

const platformSchema = snapshotMetadata.inputSchema.properties?.platform;
const input = snapshotMetadata.readInput({ platform: 'web' }) as { platform?: unknown };
assert.deepEqual(platformSchema?.enum, ['ios', 'macos', 'android', 'linux', 'web', 'apple']);
assert.equal(input.platform, 'web');
});
63 changes: 62 additions & 1 deletion src/core/__tests__/capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { isCommandSupportedOnDevice, unsupportedHintForDevice } from '../capabilities.ts';
import type { DeviceInfo } from '../../utils/device.ts';
import { matchesPlatformSelector, type DeviceInfo } from '../../utils/device.ts';
import { WEB_DESKTOP_DEVICE } from '../../__tests__/test-utils/index.ts';

const iosSimulator: DeviceInfo = {
platform: 'ios',
Expand Down Expand Up @@ -47,6 +48,8 @@ const linuxDevice: DeviceInfo = {
target: 'desktop',
};

const webDevice = WEB_DESKTOP_DEVICE;

const tvOsSimulator: DeviceInfo = {
platform: 'ios',
id: 'tv-sim-1',
Expand Down Expand Up @@ -384,6 +387,64 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported
);
});

test('web supports only the initial browser interaction slice', () => {
assertCommandSupport(
[
'click',
'close',
'fill',
'find',
'get',
'is',
'open',
'press',
'screenshot',
'scroll',
'snapshot',
'type',
'wait',
],
[{ device: webDevice, expected: true, label: 'on web' }],
);
assertCommandSupport(
[
'alert',
'app-switcher',
'apps',
'back',
'boot',
'clipboard',
'diff',
'fling',
'focus',
'home',
'install',
'install-from-source',
'keyboard',
'logs',
'longpress',
'network',
'pan',
'perf',
'pinch',
'push',
'record',
'reinstall',
'rotate',
'settings',
'shutdown',
'swipe',
'trigger-app-event',
],
[{ device: webDevice, expected: false, label: 'on web' }],
);
});

test('apple selector does not match web platform', () => {
assert.equal(matchesPlatformSelector(webDevice.platform, 'apple'), false);
assert.equal(matchesPlatformSelector(webDevice.platform, 'web'), true);
});

test('unknown commands default to supported', () => {
assert.equal(isCommandSupportedOnDevice('some-future-cmd', iosSimulator), true);
assert.equal(isCommandSupportedOnDevice('some-future-cmd', androidDevice), true);
Expand Down
25 changes: 25 additions & 0 deletions src/core/__tests__/dispatch-resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ const bootedSimulator: DeviceInfo = {
booted: true,
};

const webDesktop: DeviceInfo = {
platform: 'web',
id: 'agent-browser-chrome',
name: 'Agent Browser Chrome',
kind: 'device',
target: 'desktop',
booted: true,
};

beforeEach(() => {
mockFindBootableIosSimulator.mockReset();
mockFindBootableIosSimulator.mockResolvedValue(null);
Expand Down Expand Up @@ -216,6 +225,22 @@ test('resolveTargetDevice treats empty injected inventory as authoritative', asy
assert.equal(mockListAppleDevices.mock.calls.length, 0);
});

test('resolveTargetDevice resolves web through generic inventory without Apple fallback', async () => {
const result = await withDeviceInventoryProvider(
async (request) => {
assert.equal(request.platform, 'web');
assert.equal(request.deviceName, 'Agent Browser Chrome');
return [webDesktop];
},
async () => await resolveTargetDevice({ platform: 'web', device: 'Agent Browser Chrome' }),
);

assert.equal(result.platform, 'web');
assert.equal(result.id, 'agent-browser-chrome');
assert.equal(mockFindBootableIosSimulator.mock.calls.length, 0);
assert.equal(mockListAppleDevices.mock.calls.length, 0);
});

test('resolveTargetDevice fast-paths explicit macOS without Apple mobile discovery', async () => {
const result = await resolveTargetDevice({ platform: 'macos' });

Expand Down
34 changes: 23 additions & 11 deletions src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type CommandCapability = {
apple?: KindMatrix;
android?: KindMatrix;
linux?: KindMatrix;
web?: KindMatrix;
supports?: (device: DeviceInfo) => boolean;
/** Optional actionable hint surfaced when this command is rejected at admission for `device`. */
unsupportedHint?: (device: DeviceInfo) => string | undefined;
Expand Down Expand Up @@ -39,12 +40,17 @@ const synthesisGestureUnsupportedHint = (device: DeviceInfo): string | undefined
// Linux device kind is always 'device' (local desktop).
const LINUX_DEVICE: KindMatrix = { device: true };
const LINUX_NONE: KindMatrix = {};
const WEB_DEVICE: KindMatrix = { device: true };
const ALL_DEVICE_COMMAND_CAPABILITY = {
apple: { simulator: true, device: true },
android: { emulator: true, device: true, unknown: true },
linux: LINUX_DEVICE,
} as const satisfies CommandCapability;
const APP_RUNTIME_CAPABILITY = ALL_DEVICE_COMMAND_CAPABILITY;
const WEB_COMMAND_CAPABILITY = {
...ALL_DEVICE_COMMAND_CAPABILITY,
web: WEB_DEVICE,
} as const satisfies CommandCapability;
const APP_RUNTIME_CAPABILITY = WEB_COMMAND_CAPABILITY;
const APP_INVENTORY_CAPABILITY = {
apple: { simulator: true, device: true },
android: { emulator: true, device: true, unknown: true },
Expand Down Expand Up @@ -124,6 +130,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
apple: { simulator: true, device: true },
android: { emulator: true, device: true, unknown: true },
linux: LINUX_DEVICE,
web: WEB_DEVICE,
},
clipboard: {
apple: { simulator: true, device: true },
Expand All @@ -147,19 +154,20 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
apple: { simulator: true, device: true },
android: { emulator: true, device: true, unknown: true },
linux: LINUX_DEVICE,
web: WEB_DEVICE,
},
fling: {
apple: { simulator: true, device: true },
android: { emulator: true, device: true, unknown: true },
linux: LINUX_NONE,
},
snapshot: ALL_DEVICE_COMMAND_CAPABILITY,
snapshot: WEB_COMMAND_CAPABILITY,
diff: ALL_DEVICE_COMMAND_CAPABILITY,
screenshot: ALL_DEVICE_COMMAND_CAPABILITY,
wait: ALL_DEVICE_COMMAND_CAPABILITY,
get: ALL_DEVICE_COMMAND_CAPABILITY,
find: ALL_DEVICE_COMMAND_CAPABILITY,
is: ALL_DEVICE_COMMAND_CAPABILITY,
screenshot: WEB_COMMAND_CAPABILITY,
wait: WEB_COMMAND_CAPABILITY,
get: WEB_COMMAND_CAPABILITY,
find: WEB_COMMAND_CAPABILITY,
is: WEB_COMMAND_CAPABILITY,
focus: {
apple: { simulator: true, device: true },
android: { emulator: true, device: true, unknown: true },
Expand Down Expand Up @@ -200,6 +208,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
apple: { simulator: true, device: true },
android: { emulator: true, device: true, unknown: true },
linux: LINUX_DEVICE,
web: WEB_DEVICE,
},
push: {
apple: { simulator: true },
Expand Down Expand Up @@ -228,6 +237,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
apple: { simulator: true, device: true },
android: { emulator: true, device: true, unknown: true },
linux: LINUX_DEVICE,
web: WEB_DEVICE,
},
swipe: {
apple: { simulator: true, device: true },
Expand All @@ -246,17 +256,19 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
android: { emulator: true, device: true, unknown: true },
linux: LINUX_NONE,
},
type: ALL_DEVICE_COMMAND_CAPABILITY,
type: WEB_COMMAND_CAPABILITY,
};

export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): boolean {
const capability = COMMAND_CAPABILITY_MATRIX[command];
if (!capability) return true;
const byPlatform = isApplePlatform(device.platform)
? capability.apple
: device.platform === 'linux'
? capability.linux
: capability.android;
: device.platform === 'android'
? capability.android
: device.platform === 'linux'
? capability.linux
: capability.web;
if (!byPlatform) return false;
if (capability.supports && !capability.supports(device)) return false;
const kind = (device.kind ?? 'unknown') as keyof KindMatrix;
Expand Down
5 changes: 3 additions & 2 deletions src/core/dispatch-resolve.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { AppError } from '../utils/errors.ts';
import {
isApplePlatform,
normalizePlatformSelector,
resolveDevice,
resolveAppleSimulatorSetPathForSelector,
Expand Down Expand Up @@ -36,7 +37,7 @@ export type DeviceInventoryProvider = (
) => Promise<DeviceInfo[] | null | undefined>;

type AppleDeviceSelector = {
platform?: Exclude<PlatformSelector, 'android'>;
platform?: 'ios' | 'macos' | 'apple';
target?: DeviceTarget;
deviceName?: string;
udid?: string;
Expand Down Expand Up @@ -248,7 +249,7 @@ function isAppleResolutionSelector(selector: {
platform?: PlatformSelector;
target?: DeviceTarget;
}): boolean {
return !!selector.platform && selector.platform !== 'android' && selector.platform !== 'linux';
return isApplePlatform(selector.platform);
}

function readResolveTargetDeviceCache(cacheKey: string): DeviceInfo | undefined {
Expand Down
13 changes: 13 additions & 0 deletions src/core/platform-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,22 @@ export type DeviceInventoryRequest = {
androidSerialAllowlist?: string[];
};

const WEB_DESKTOP_DEVICE: DeviceInfo = {
platform: 'web',
id: 'agent-browser-chrome',
name: 'Agent Browser Chrome',
kind: 'device',
target: 'desktop',
booted: true,
};

export async function listLocalDeviceInventory(
request: DeviceInventoryRequest,
): Promise<DeviceInfo[]> {
if (request.platform === 'web') {
return [WEB_DESKTOP_DEVICE];
}

if (shouldUseHostMacFastPath(request)) {
const { listMacosDevices } = await import('../platforms/macos/devices.ts');
return await listMacosDevices();
Expand Down
21 changes: 21 additions & 0 deletions src/daemon/handlers/__tests__/session-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,24 @@ test('appstate returns missing-session error for explicit session flag', async (
expect(response.error.message).toMatch(/Run open with --session named first/i);
}
});

test('appstate rejects web before Android app-state backend dispatch', async () => {
const response = await handleSessionStateCommands({
req: {
token: 't',
session: 'default',
command: 'appstate',
positionals: [],
flags: { platform: 'web' },
},
sessionName: 'default',
sessionStore: makeSessionStore('agent-device-session-state-'),
});

expect(response).toBeTruthy();
expect(response?.ok).toBe(false);
if (response && !response.ok) {
expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
expect(response.error.message).toMatch(/appstate is not supported on web/i);
}
});
3 changes: 3 additions & 0 deletions src/daemon/handlers/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ async function handleAppStateCommand(params: {
if (device.platform === 'macos') {
return errorResponse('SESSION_NOT_FOUND', MACOS_APPSTATE_SESSION_REQUIRED_MESSAGE);
}
if (device.platform === 'web') {
return errorResponse('UNSUPPORTED_OPERATION', 'appstate is not supported on web.');
}

const { getAndroidAppState } = await import('../../platforms/android/app-lifecycle.ts');
const state = await getAndroidAppState(device);
Expand Down
3 changes: 2 additions & 1 deletion src/daemon/request-lock-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ function targetSelectorsConflict(
return target === 'desktop';
case 'macos':
case 'linux':
case 'web':
return target !== 'desktop';
case 'apple':
return false;
Expand Down Expand Up @@ -225,8 +226,8 @@ function freshSessionSelectorKeysForPlatform(
? ['udid', 'serial', 'androidDeviceAllowlist', 'iosSimulatorDeviceSet']
: ['serial', 'androidDeviceAllowlist'];
case 'macos':
return ['udid', 'serial', 'iosSimulatorDeviceSet', 'androidDeviceAllowlist'];
case 'linux':
case 'web':
return ['udid', 'serial', 'iosSimulatorDeviceSet', 'androidDeviceAllowlist'];
default:
return assertNever(lockPlatform);
Expand Down
Loading
Loading