Skip to content

Commit 188715b

Browse files
authored
feat: add web platform vocabulary (#824)
* feat: add web platform vocabulary * fix: tighten web platform admission
1 parent f9a9662 commit 188715b

17 files changed

Lines changed: 200 additions & 24 deletions

src/__tests__/test-utils/device-fixtures.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ export const LINUX_DEVICE: DeviceInfo = {
4141
target: 'desktop',
4242
};
4343

44+
export const WEB_DESKTOP_DEVICE: DeviceInfo = {
45+
platform: 'web',
46+
id: 'agent-browser-chrome',
47+
name: 'Agent Browser Chrome',
48+
kind: 'device',
49+
target: 'desktop',
50+
booted: true,
51+
};
52+
4453
export const ANDROID_TV_DEVICE: DeviceInfo = {
4554
platform: 'android',
4655
id: 'and-tv-1',

src/__tests__/test-utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export {
66
LINUX_DEVICE,
77
MACOS_DEVICE,
88
TVOS_SIMULATOR,
9+
WEB_DESKTOP_DEVICE,
910
} from './device-fixtures.ts';
1011

1112
export {

src/client-normalizers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,8 @@ export function normalizeOpenDevice(
183183
(platform !== 'ios' &&
184184
platform !== 'macos' &&
185185
platform !== 'android' &&
186-
platform !== 'linux') ||
186+
platform !== 'linux' &&
187+
platform !== 'web') ||
187188
!id ||
188189
!name
189190
) {

src/commands/__tests__/command-surface-metadata.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import assert from 'node:assert/strict';
22
import { test } from 'vitest';
33
import { listMcpExposedCommandNames } from '../../command-catalog.ts';
4-
import { listCommandMetadataNames, listMcpCommandMetadata } from '../command-metadata.ts';
4+
import {
5+
listCommandMetadata,
6+
listCommandMetadataNames,
7+
listMcpCommandMetadata,
8+
} from '../command-metadata.ts';
59
import { listExecutableCommandNames } from '../command-surface.ts';
610

711
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
2327
test('CI-only prepare command stays out of MCP tool surface', () => {
2428
assert.equal(listMcpExposedCommandNames().includes('prepare'), false);
2529
});
30+
31+
test('common command input accepts web platform selector', () => {
32+
const snapshotMetadata = listCommandMetadata().find((metadata) => metadata.name === 'snapshot');
33+
if (!snapshotMetadata) throw new Error('Expected snapshot command metadata');
34+
35+
const platformSchema = snapshotMetadata.inputSchema.properties?.platform;
36+
const input = snapshotMetadata.readInput({ platform: 'web' }) as { platform?: unknown };
37+
assert.deepEqual(platformSchema?.enum, ['ios', 'macos', 'android', 'linux', 'web', 'apple']);
38+
assert.equal(input.platform, 'web');
39+
});

src/core/__tests__/capabilities.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
33
import { isCommandSupportedOnDevice, unsupportedHintForDevice } from '../capabilities.ts';
4-
import type { DeviceInfo } from '../../utils/device.ts';
4+
import { matchesPlatformSelector, type DeviceInfo } from '../../utils/device.ts';
5+
import { WEB_DESKTOP_DEVICE } from '../../__tests__/test-utils/index.ts';
56

67
const iosSimulator: DeviceInfo = {
78
platform: 'ios',
@@ -47,6 +48,8 @@ const linuxDevice: DeviceInfo = {
4748
target: 'desktop',
4849
};
4950

51+
const webDevice = WEB_DESKTOP_DEVICE;
52+
5053
const tvOsSimulator: DeviceInfo = {
5154
platform: 'ios',
5255
id: 'tv-sim-1',
@@ -384,6 +387,64 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported
384387
);
385388
});
386389

390+
test('web supports only the initial browser interaction slice', () => {
391+
assertCommandSupport(
392+
[
393+
'click',
394+
'close',
395+
'fill',
396+
'find',
397+
'get',
398+
'is',
399+
'open',
400+
'press',
401+
'screenshot',
402+
'scroll',
403+
'snapshot',
404+
'type',
405+
'wait',
406+
],
407+
[{ device: webDevice, expected: true, label: 'on web' }],
408+
);
409+
assertCommandSupport(
410+
[
411+
'alert',
412+
'app-switcher',
413+
'apps',
414+
'back',
415+
'boot',
416+
'clipboard',
417+
'diff',
418+
'fling',
419+
'focus',
420+
'home',
421+
'install',
422+
'install-from-source',
423+
'keyboard',
424+
'logs',
425+
'longpress',
426+
'network',
427+
'pan',
428+
'perf',
429+
'pinch',
430+
'push',
431+
'record',
432+
'reinstall',
433+
'rotate',
434+
'settings',
435+
'shutdown',
436+
'swipe',
437+
'trigger-app-event',
438+
],
439+
[{ device: webDevice, expected: false, label: 'on web' }],
440+
);
441+
});
442+
443+
test('apple selector does not match web platform', () => {
444+
assert.equal(matchesPlatformSelector(webDevice.platform, 'apple'), false);
445+
assert.equal(matchesPlatformSelector(webDevice.platform, 'web'), true);
446+
});
447+
387448
test('unknown commands default to supported', () => {
388449
assert.equal(isCommandSupportedOnDevice('some-future-cmd', iosSimulator), true);
389450
assert.equal(isCommandSupportedOnDevice('some-future-cmd', androidDevice), true);

src/core/__tests__/dispatch-resolve.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ const bootedSimulator: DeviceInfo = {
5151
booted: true,
5252
};
5353

54+
const webDesktop: DeviceInfo = {
55+
platform: 'web',
56+
id: 'agent-browser-chrome',
57+
name: 'Agent Browser Chrome',
58+
kind: 'device',
59+
target: 'desktop',
60+
booted: true,
61+
};
62+
5463
beforeEach(() => {
5564
mockFindBootableIosSimulator.mockReset();
5665
mockFindBootableIosSimulator.mockResolvedValue(null);
@@ -216,6 +225,22 @@ test('resolveTargetDevice treats empty injected inventory as authoritative', asy
216225
assert.equal(mockListAppleDevices.mock.calls.length, 0);
217226
});
218227

228+
test('resolveTargetDevice resolves web through generic inventory without Apple fallback', async () => {
229+
const result = await withDeviceInventoryProvider(
230+
async (request) => {
231+
assert.equal(request.platform, 'web');
232+
assert.equal(request.deviceName, 'Agent Browser Chrome');
233+
return [webDesktop];
234+
},
235+
async () => await resolveTargetDevice({ platform: 'web', device: 'Agent Browser Chrome' }),
236+
);
237+
238+
assert.equal(result.platform, 'web');
239+
assert.equal(result.id, 'agent-browser-chrome');
240+
assert.equal(mockFindBootableIosSimulator.mock.calls.length, 0);
241+
assert.equal(mockListAppleDevices.mock.calls.length, 0);
242+
});
243+
219244
test('resolveTargetDevice fast-paths explicit macOS without Apple mobile discovery', async () => {
220245
const result = await resolveTargetDevice({ platform: 'macos' });
221246

src/core/capabilities.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type CommandCapability = {
1111
apple?: KindMatrix;
1212
android?: KindMatrix;
1313
linux?: KindMatrix;
14+
web?: KindMatrix;
1415
supports?: (device: DeviceInfo) => boolean;
1516
/** Optional actionable hint surfaced when this command is rejected at admission for `device`. */
1617
unsupportedHint?: (device: DeviceInfo) => string | undefined;
@@ -39,12 +40,17 @@ const synthesisGestureUnsupportedHint = (device: DeviceInfo): string | undefined
3940
// Linux device kind is always 'device' (local desktop).
4041
const LINUX_DEVICE: KindMatrix = { device: true };
4142
const LINUX_NONE: KindMatrix = {};
43+
const WEB_DEVICE: KindMatrix = { device: true };
4244
const ALL_DEVICE_COMMAND_CAPABILITY = {
4345
apple: { simulator: true, device: true },
4446
android: { emulator: true, device: true, unknown: true },
4547
linux: LINUX_DEVICE,
4648
} as const satisfies CommandCapability;
47-
const APP_RUNTIME_CAPABILITY = ALL_DEVICE_COMMAND_CAPABILITY;
49+
const WEB_COMMAND_CAPABILITY = {
50+
...ALL_DEVICE_COMMAND_CAPABILITY,
51+
web: WEB_DEVICE,
52+
} as const satisfies CommandCapability;
53+
const APP_RUNTIME_CAPABILITY = WEB_COMMAND_CAPABILITY;
4854
const APP_INVENTORY_CAPABILITY = {
4955
apple: { simulator: true, device: true },
5056
android: { emulator: true, device: true, unknown: true },
@@ -124,6 +130,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
124130
apple: { simulator: true, device: true },
125131
android: { emulator: true, device: true, unknown: true },
126132
linux: LINUX_DEVICE,
133+
web: WEB_DEVICE,
127134
},
128135
clipboard: {
129136
apple: { simulator: true, device: true },
@@ -147,19 +154,20 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
147154
apple: { simulator: true, device: true },
148155
android: { emulator: true, device: true, unknown: true },
149156
linux: LINUX_DEVICE,
157+
web: WEB_DEVICE,
150158
},
151159
fling: {
152160
apple: { simulator: true, device: true },
153161
android: { emulator: true, device: true, unknown: true },
154162
linux: LINUX_NONE,
155163
},
156-
snapshot: ALL_DEVICE_COMMAND_CAPABILITY,
164+
snapshot: WEB_COMMAND_CAPABILITY,
157165
diff: ALL_DEVICE_COMMAND_CAPABILITY,
158-
screenshot: ALL_DEVICE_COMMAND_CAPABILITY,
159-
wait: ALL_DEVICE_COMMAND_CAPABILITY,
160-
get: ALL_DEVICE_COMMAND_CAPABILITY,
161-
find: ALL_DEVICE_COMMAND_CAPABILITY,
162-
is: ALL_DEVICE_COMMAND_CAPABILITY,
166+
screenshot: WEB_COMMAND_CAPABILITY,
167+
wait: WEB_COMMAND_CAPABILITY,
168+
get: WEB_COMMAND_CAPABILITY,
169+
find: WEB_COMMAND_CAPABILITY,
170+
is: WEB_COMMAND_CAPABILITY,
163171
focus: {
164172
apple: { simulator: true, device: true },
165173
android: { emulator: true, device: true, unknown: true },
@@ -200,6 +208,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
200208
apple: { simulator: true, device: true },
201209
android: { emulator: true, device: true, unknown: true },
202210
linux: LINUX_DEVICE,
211+
web: WEB_DEVICE,
203212
},
204213
push: {
205214
apple: { simulator: true },
@@ -228,6 +237,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
228237
apple: { simulator: true, device: true },
229238
android: { emulator: true, device: true, unknown: true },
230239
linux: LINUX_DEVICE,
240+
web: WEB_DEVICE,
231241
},
232242
swipe: {
233243
apple: { simulator: true, device: true },
@@ -246,17 +256,19 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
246256
android: { emulator: true, device: true, unknown: true },
247257
linux: LINUX_NONE,
248258
},
249-
type: ALL_DEVICE_COMMAND_CAPABILITY,
259+
type: WEB_COMMAND_CAPABILITY,
250260
};
251261

252262
export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): boolean {
253263
const capability = COMMAND_CAPABILITY_MATRIX[command];
254264
if (!capability) return true;
255265
const byPlatform = isApplePlatform(device.platform)
256266
? capability.apple
257-
: device.platform === 'linux'
258-
? capability.linux
259-
: capability.android;
267+
: device.platform === 'android'
268+
? capability.android
269+
: device.platform === 'linux'
270+
? capability.linux
271+
: capability.web;
260272
if (!byPlatform) return false;
261273
if (capability.supports && !capability.supports(device)) return false;
262274
const kind = (device.kind ?? 'unknown') as keyof KindMatrix;

src/core/dispatch-resolve.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AsyncLocalStorage } from 'node:async_hooks';
22
import { AppError } from '../utils/errors.ts';
33
import {
4+
isApplePlatform,
45
normalizePlatformSelector,
56
resolveDevice,
67
resolveAppleSimulatorSetPathForSelector,
@@ -36,7 +37,7 @@ export type DeviceInventoryProvider = (
3637
) => Promise<DeviceInfo[] | null | undefined>;
3738

3839
type AppleDeviceSelector = {
39-
platform?: Exclude<PlatformSelector, 'android'>;
40+
platform?: 'ios' | 'macos' | 'apple';
4041
target?: DeviceTarget;
4142
deviceName?: string;
4243
udid?: string;
@@ -248,7 +249,7 @@ function isAppleResolutionSelector(selector: {
248249
platform?: PlatformSelector;
249250
target?: DeviceTarget;
250251
}): boolean {
251-
return !!selector.platform && selector.platform !== 'android' && selector.platform !== 'linux';
252+
return isApplePlatform(selector.platform);
252253
}
253254

254255
function readResolveTargetDeviceCache(cacheKey: string): DeviceInfo | undefined {

src/core/platform-inventory.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,22 @@ export type DeviceInventoryRequest = {
1010
androidSerialAllowlist?: string[];
1111
};
1212

13+
const WEB_DESKTOP_DEVICE: DeviceInfo = {
14+
platform: 'web',
15+
id: 'agent-browser-chrome',
16+
name: 'Agent Browser Chrome',
17+
kind: 'device',
18+
target: 'desktop',
19+
booted: true,
20+
};
21+
1322
export async function listLocalDeviceInventory(
1423
request: DeviceInventoryRequest,
1524
): Promise<DeviceInfo[]> {
25+
if (request.platform === 'web') {
26+
return [WEB_DESKTOP_DEVICE];
27+
}
28+
1629
if (shouldUseHostMacFastPath(request)) {
1730
const { listMacosDevices } = await import('../platforms/macos/devices.ts');
1831
return await listMacosDevices();

src/daemon/handlers/__tests__/session-state.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,24 @@ test('appstate returns missing-session error for explicit session flag', async (
4444
expect(response.error.message).toMatch(/Run open with --session named first/i);
4545
}
4646
});
47+
48+
test('appstate rejects web before Android app-state backend dispatch', async () => {
49+
const response = await handleSessionStateCommands({
50+
req: {
51+
token: 't',
52+
session: 'default',
53+
command: 'appstate',
54+
positionals: [],
55+
flags: { platform: 'web' },
56+
},
57+
sessionName: 'default',
58+
sessionStore: makeSessionStore('agent-device-session-state-'),
59+
});
60+
61+
expect(response).toBeTruthy();
62+
expect(response?.ok).toBe(false);
63+
if (response && !response.ok) {
64+
expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
65+
expect(response.error.message).toMatch(/appstate is not supported on web/i);
66+
}
67+
});

0 commit comments

Comments
 (0)