diff --git a/src/__tests__/test-utils/device-fixtures.ts b/src/__tests__/test-utils/device-fixtures.ts index 87c0ea7d9..02675fad2 100644 --- a/src/__tests__/test-utils/device-fixtures.ts +++ b/src/__tests__/test-utils/device-fixtures.ts @@ -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', diff --git a/src/__tests__/test-utils/index.ts b/src/__tests__/test-utils/index.ts index 4651c98c6..a648875a7 100644 --- a/src/__tests__/test-utils/index.ts +++ b/src/__tests__/test-utils/index.ts @@ -6,6 +6,7 @@ export { LINUX_DEVICE, MACOS_DEVICE, TVOS_SIMULATOR, + WEB_DESKTOP_DEVICE, } from './device-fixtures.ts'; export { diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index 7310b5c42..596eea50a 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -183,7 +183,8 @@ export function normalizeOpenDevice( (platform !== 'ios' && platform !== 'macos' && platform !== 'android' && - platform !== 'linux') || + platform !== 'linux' && + platform !== 'web') || !id || !name ) { diff --git a/src/commands/__tests__/command-surface-metadata.test.ts b/src/commands/__tests__/command-surface-metadata.test.ts index bf6714fea..dda5fcf07 100644 --- a/src/commands/__tests__/command-surface-metadata.test.ts +++ b/src/commands/__tests__/command-surface-metadata.test.ts @@ -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', () => { @@ -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'); +}); diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index f7ebe49d0..fc53e9402 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -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', @@ -47,6 +48,8 @@ const linuxDevice: DeviceInfo = { target: 'desktop', }; +const webDevice = WEB_DESKTOP_DEVICE; + const tvOsSimulator: DeviceInfo = { platform: 'ios', id: 'tv-sim-1', @@ -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); diff --git a/src/core/__tests__/dispatch-resolve.test.ts b/src/core/__tests__/dispatch-resolve.test.ts index ba28f135d..2e53431e5 100644 --- a/src/core/__tests__/dispatch-resolve.test.ts +++ b/src/core/__tests__/dispatch-resolve.test.ts @@ -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); @@ -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' }); diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index fe276464b..443cf280d 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -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; @@ -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 }, @@ -124,6 +130,7 @@ const COMMAND_CAPABILITY_MATRIX: Record = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, linux: LINUX_DEVICE, + web: WEB_DEVICE, }, clipboard: { apple: { simulator: true, device: true }, @@ -147,19 +154,20 @@ const COMMAND_CAPABILITY_MATRIX: Record = { 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 }, @@ -200,6 +208,7 @@ const COMMAND_CAPABILITY_MATRIX: Record = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, linux: LINUX_DEVICE, + web: WEB_DEVICE, }, push: { apple: { simulator: true }, @@ -228,6 +237,7 @@ const COMMAND_CAPABILITY_MATRIX: Record = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, linux: LINUX_DEVICE, + web: WEB_DEVICE, }, swipe: { apple: { simulator: true, device: true }, @@ -246,7 +256,7 @@ const COMMAND_CAPABILITY_MATRIX: Record = { 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 { @@ -254,9 +264,11 @@ export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): 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; diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 57307d6c2..e8d0db7e1 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -1,6 +1,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { AppError } from '../utils/errors.ts'; import { + isApplePlatform, normalizePlatformSelector, resolveDevice, resolveAppleSimulatorSetPathForSelector, @@ -36,7 +37,7 @@ export type DeviceInventoryProvider = ( ) => Promise; type AppleDeviceSelector = { - platform?: Exclude; + platform?: 'ios' | 'macos' | 'apple'; target?: DeviceTarget; deviceName?: string; udid?: string; @@ -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 { diff --git a/src/core/platform-inventory.ts b/src/core/platform-inventory.ts index 5835b08b6..125b70d3c 100644 --- a/src/core/platform-inventory.ts +++ b/src/core/platform-inventory.ts @@ -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 { + if (request.platform === 'web') { + return [WEB_DESKTOP_DEVICE]; + } + if (shouldUseHostMacFastPath(request)) { const { listMacosDevices } = await import('../platforms/macos/devices.ts'); return await listMacosDevices(); diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index 71ba1709a..26dfeb6d0 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -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); + } +}); diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index 78c465bda..48aa0bb09 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -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); diff --git a/src/daemon/request-lock-policy.ts b/src/daemon/request-lock-policy.ts index 1b03211e3..24b3e8a4a 100644 --- a/src/daemon/request-lock-policy.ts +++ b/src/daemon/request-lock-policy.ts @@ -190,6 +190,7 @@ function targetSelectorsConflict( return target === 'desktop'; case 'macos': case 'linux': + case 'web': return target !== 'desktop'; case 'apple': return false; @@ -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); diff --git a/src/remote-config-schema.ts b/src/remote-config-schema.ts index 7e5b8a741..6ca159a2a 100644 --- a/src/remote-config-schema.ts +++ b/src/remote-config-schema.ts @@ -5,7 +5,7 @@ import type { LeaseBackend, SessionIsolationMode, } from './contracts.ts'; -import type { DeviceTarget, PlatformSelector } from './utils/device.ts'; +import { PLATFORM_SELECTORS, type DeviceTarget, type PlatformSelector } from './utils/device.ts'; import type { MetroPrepareKind } from './client-metro.ts'; export type RemoteConfigMetroOptions = { @@ -81,7 +81,7 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ type: 'enum', enumValues: ['ios-simulator', 'ios-instance', 'android-instance'], }, - { key: 'platform', type: 'enum', enumValues: ['ios', 'macos', 'android', 'linux', 'apple'] }, + { key: 'platform', type: 'enum', enumValues: PLATFORM_SELECTORS }, { key: 'target', type: 'enum', enumValues: ['mobile', 'tv', 'desktop'] }, { key: 'device', type: 'string' }, { key: 'udid', type: 'string' }, diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 2d41d1a90..7caee5281 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -102,6 +102,16 @@ test('parseArgs recognizes command-specific flag combinations', async () => { assert.equal(parsed.flags.target, 'tv'); }, }, + { + label: 'open --platform web', + argv: ['open', 'https://example.com', '--platform', 'web', '--target', 'desktop'], + strictFlags: true, + assertParsed: (parsed) => { + assert.equal(parsed.command, 'open'); + assert.equal(parsed.flags.platform, 'web'); + assert.equal(parsed.flags.target, 'desktop'); + }, + }, { label: 'open --surface frontmost-app', argv: ['open', '--platform', 'macos', '--surface', 'frontmost-app'], @@ -1636,7 +1646,7 @@ test('command usage shows command and global flags separately', () => { assert.match(help, /Command flags:/); assert.match(help, /--pattern one-way\|ping-pong/); assert.match(help, /Global flags:/); - assert.match(help, /--platform ios\|macos\|android\|linux\|apple/); + assert.match(help, /--platform ios\|macos\|android\|linux\|web\|apple/); }); test('back command usage documents explicit mode flags', () => { diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 8050f43ee..34b6a357a 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -322,8 +322,8 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ key: 'platform', names: ['--platform'], type: 'enum', - enumValues: ['ios', 'macos', 'android', 'linux', 'apple'], - usageLabel: '--platform ios|macos|android|linux|apple', + enumValues: ['ios', 'macos', 'android', 'linux', 'web', 'apple'], + usageLabel: '--platform ios|macos|android|linux|web|apple', usageDescription: 'Platform to target (`apple` aliases the Apple automation backend)', }, { diff --git a/src/utils/device.ts b/src/utils/device.ts index f95e00b0d..223121134 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -1,7 +1,7 @@ import { AppError } from './errors.ts'; export type ApplePlatform = 'ios' | 'macos'; -const PLATFORMS = ['ios', 'macos', 'android', 'linux'] as const; +const PLATFORMS = ['ios', 'macos', 'android', 'linux', 'web'] as const; export type Platform = (typeof PLATFORMS)[number]; export const PLATFORM_SELECTORS = [...PLATFORMS, 'apple'] as const; export type PlatformSelector = (typeof PLATFORM_SELECTORS)[number]; diff --git a/src/utils/parsing.ts b/src/utils/parsing.ts index 4a0168dcd..b22863f4d 100644 --- a/src/utils/parsing.ts +++ b/src/utils/parsing.ts @@ -109,7 +109,11 @@ function parseFiniteNumber(value: unknown): number | undefined { } function parsePlatform(value: unknown): Platform | undefined { - return value === 'ios' || value === 'macos' || value === 'android' || value === 'linux' + return value === 'ios' || + value === 'macos' || + value === 'android' || + value === 'linux' || + value === 'web' ? value : undefined; }