Skip to content

Commit bd60330

Browse files
committed
feat: add semantic web provider seam
1 parent a1558c9 commit bd60330

8 files changed

Lines changed: 319 additions & 2 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { createWebInteractor } from '../interactors/web.ts';
4+
import { AppError } from '../../utils/errors.ts';
5+
import { withWebProvider, type WebProvider } from '../../platforms/web/provider.ts';
6+
7+
test('web interactor delegates first-slice operations to the scoped provider', async () => {
8+
const calls: string[] = [];
9+
const interactor = createWebInteractor();
10+
const provider = makeWebProvider({
11+
async open(target, options) {
12+
calls.push(`open:${target}:${options?.url ?? ''}`);
13+
},
14+
async close(target) {
15+
calls.push(`close:${target ?? ''}`);
16+
},
17+
async snapshot(options) {
18+
calls.push(`snapshot:${options?.scope ?? ''}`);
19+
return {
20+
nodes: [{ index: 0, role: 'button', label: 'Submit' }],
21+
truncated: true,
22+
};
23+
},
24+
async screenshot(outPath, options) {
25+
calls.push(`screenshot:${outPath}:${options?.fullscreen === true}`);
26+
},
27+
async click(x, y) {
28+
calls.push(`click:${x}:${y}`);
29+
},
30+
async fill(x, y, text, options) {
31+
calls.push(`fill:${x}:${y}:${text}:${options?.delayMs ?? 0}`);
32+
},
33+
async typeText(text, options) {
34+
calls.push(`type:${text}:${options?.delayMs ?? 0}`);
35+
},
36+
async scroll(direction, options) {
37+
calls.push(`scroll:${direction}:${options?.pixels ?? options?.amount ?? ''}`);
38+
},
39+
});
40+
41+
const snapshot = await withWebProvider(provider, async () => {
42+
await interactor.open('https://example.test');
43+
await interactor.open('app-shell', { url: 'https://example.test/deep' });
44+
await interactor.close('app-shell');
45+
await interactor.tap(10, 20);
46+
await interactor.focus(11, 21);
47+
await interactor.fill(12, 22, 'hello', 5);
48+
await interactor.type('world', 6);
49+
await interactor.scroll('down', { pixels: 400 });
50+
await interactor.screenshot('/tmp/web.png', { fullscreen: true });
51+
return await interactor.snapshot({ scope: 'main' });
52+
});
53+
54+
assert.deepEqual(calls, [
55+
'open:https://example.test:',
56+
'open:https://example.test/deep:https://example.test/deep',
57+
'close:app-shell',
58+
'click:10:20',
59+
'click:11:21',
60+
'fill:12:22:hello:5',
61+
'type:world:6',
62+
'scroll:down:400',
63+
'screenshot:/tmp/web.png:true',
64+
'snapshot:main',
65+
]);
66+
assert.equal(snapshot.backend, 'web');
67+
assert.equal(snapshot.truncated, true);
68+
assert.deepEqual(snapshot.nodes, [{ index: 0, role: 'button', label: 'Submit' }]);
69+
});
70+
71+
test('web interactor reports unsupported operations explicitly', async () => {
72+
const interactor = createWebInteractor();
73+
74+
await assert.rejects(
75+
() => interactor.back(),
76+
(error: unknown) =>
77+
error instanceof AppError &&
78+
error.code === 'UNSUPPORTED_OPERATION' &&
79+
error.message === 'back is not supported on web',
80+
);
81+
});
82+
83+
function makeWebProvider(overrides: Partial<WebProvider> = {}): WebProvider {
84+
return {
85+
open: async () => {},
86+
close: async () => {},
87+
snapshot: async () => ({ nodes: [] }),
88+
screenshot: async () => {},
89+
click: async () => {},
90+
fill: async () => {},
91+
typeText: async () => {},
92+
scroll: async () => {},
93+
...overrides,
94+
};
95+
}

src/core/interactor-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export type SnapshotOptions = BaseSnapshotOptions & {
5757

5858
export type SnapshotResult = Omit<BackendSnapshotResult, 'backend' | 'nodes'> & {
5959
nodes?: RawSnapshotNode[];
60-
backend: Extract<SnapshotBackend, 'android' | 'xctest' | 'linux-atspi'>;
60+
backend: Extract<SnapshotBackend, 'android' | 'xctest' | 'linux-atspi' | 'web'>;
6161
};
6262

6363
export type Interactor = {

src/core/interactors.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ export async function getInteractor(
1515
const { createLinuxInteractor } = await import('./interactors/linux.ts');
1616
return createLinuxInteractor();
1717
}
18+
case 'web': {
19+
const { createWebInteractor } = await import('./interactors/web.ts');
20+
return createWebInteractor();
21+
}
1822
case 'ios':
1923
case 'macos': {
2024
const { createAppleInteractor } = await import('./interactors/apple.ts');

src/core/interactors/web.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { Interactor } from '../interactor-types.ts';
2+
import { AppError } from '../../utils/errors.ts';
3+
import { withDiagnosticTimer } from '../../utils/diagnostics.ts';
4+
import { resolveWebProvider } from '../../platforms/web/provider.ts';
5+
6+
export function createWebInteractor(): Interactor {
7+
const provider = () => resolveWebProvider();
8+
return {
9+
open: (target, options) => provider().open(options?.url ?? target, { url: options?.url }),
10+
openDevice: () => provider().open('about:blank'),
11+
close: (target) => provider().close(target),
12+
tap: (x, y) => provider().click(x, y),
13+
doubleTap: () => unsupportedWebOperation('doubleTap'),
14+
swipe: () => unsupportedWebOperation('swipe'),
15+
pan: () => unsupportedWebOperation('pan'),
16+
fling: () => unsupportedWebOperation('fling'),
17+
longPress: () => unsupportedWebOperation('longPress'),
18+
focus: (x, y) => provider().click(x, y),
19+
type: (text, delayMs) => provider().typeText(text, { delayMs }),
20+
fill: (x, y, text, delayMs) => provider().fill(x, y, text, { delayMs }),
21+
scroll: (direction, options) => provider().scroll(direction, options),
22+
pinch: () => unsupportedWebOperation('pinch'),
23+
screenshot: (outPath, options) => provider().screenshot(outPath, options),
24+
snapshot: async (options) => {
25+
const result = await withDiagnosticTimer(
26+
'snapshot_capture',
27+
async () => await provider().snapshot(options),
28+
{ backend: 'web' },
29+
);
30+
return {
31+
nodes: result.nodes,
32+
truncated: result.truncated ?? false,
33+
backend: 'web',
34+
};
35+
},
36+
back: () => unsupportedWebOperation('back'),
37+
home: () => unsupportedWebOperation('home'),
38+
rotate: () => unsupportedWebOperation('rotate'),
39+
rotateGesture: () => unsupportedWebOperation('rotateGesture'),
40+
transformGesture: () => unsupportedWebOperation('transformGesture'),
41+
appSwitcher: () => unsupportedWebOperation('appSwitcher'),
42+
readClipboard: () => unsupportedWebOperation('readClipboard'),
43+
writeClipboard: () => unsupportedWebOperation('writeClipboard'),
44+
setSetting: () => unsupportedWebOperation('setSetting'),
45+
};
46+
}
47+
48+
async function unsupportedWebOperation(operation: string): Promise<never> {
49+
throw new AppError('UNSUPPORTED_OPERATION', `${operation} is not supported on web`);
50+
}

src/daemon/__tests__/request-platform-providers.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ import { test } from 'vitest';
33
import {
44
ANDROID_EMULATOR,
55
IOS_SIMULATOR,
6+
WEB_DESKTOP_DEVICE,
67
makeAndroidSession,
78
makeIosSession,
9+
makeSession,
810
} from '../../__tests__/test-utils/index.ts';
911
import { withTargetDeviceResolutionScope } from '../../core/dispatch-resolve.ts';
1012
import { createLocalAppleToolProvider, runXcrun } from '../../platforms/ios/tool-provider.ts';
13+
import { resolveWebProvider, type WebProvider } from '../../platforms/web/provider.ts';
1114
import type { DeviceInfo } from '../../utils/device.ts';
1215
import { withRequestPlatformProviderScope } from '../request-platform-providers.ts';
1316
import type { DaemonRequest } from '../types.ts';
@@ -194,6 +197,66 @@ test('request platform provider scopes stay isolated across concurrent requests'
194197
assert.deepEqual(appleCalls, [`ios-session:${IOS_SIMULATOR.id}:list devices -j`]);
195198
});
196199

200+
test('request platform provider scope applies web provider only for web sessions', async () => {
201+
const calls: string[] = [];
202+
const webProvider = makeWebProvider({
203+
async open(target) {
204+
calls.push(`open:${target}`);
205+
},
206+
});
207+
208+
await withRequestPlatformProviderScope(
209+
{
210+
req: request('open'),
211+
existingSession: makeSession('web-session', { device: WEB_DESKTOP_DEVICE }),
212+
providers: {
213+
webProvider: ({ device, session }) => {
214+
calls.push(`${session?.name}:${device.id}`);
215+
return webProvider;
216+
},
217+
linuxToolProvider: () => {
218+
throw new Error('Linux provider should not apply to a web session');
219+
},
220+
},
221+
},
222+
async () => await resolveWebProvider().open('https://example.test'),
223+
);
224+
225+
assert.deepEqual(calls, ['web-session:agent-browser-chrome', 'open:https://example.test']);
226+
});
227+
228+
test('request platform provider scope follows explicit web selector', async () => {
229+
const seenDevices: string[] = [];
230+
231+
await withTargetDeviceResolutionScope(
232+
async () => [WEB_DESKTOP_DEVICE],
233+
async () =>
234+
await withRequestPlatformProviderScope(
235+
{
236+
req: {
237+
...request('snapshot'),
238+
flags: {
239+
platform: 'web',
240+
},
241+
},
242+
existingSession: undefined,
243+
providers: {
244+
webProvider: ({ device, session }) => {
245+
seenDevices.push(`${session?.name ?? 'none'}:${device.id}`);
246+
return makeWebProvider();
247+
},
248+
appleToolProvider: () => {
249+
throw new Error('Apple provider should not apply to a web request');
250+
},
251+
},
252+
},
253+
async () => await resolveWebProvider().snapshot(),
254+
),
255+
);
256+
257+
assert.deepEqual(seenDevices, ['none:agent-browser-chrome']);
258+
});
259+
197260
function request(command: string): DaemonRequest {
198261
return {
199262
token: 'test-token',
@@ -204,3 +267,17 @@ function request(command: string): DaemonRequest {
204267
meta: { requestId: `req-${command}` },
205268
};
206269
}
270+
271+
function makeWebProvider(overrides: Partial<WebProvider> = {}): WebProvider {
272+
return {
273+
open: async () => {},
274+
close: async () => {},
275+
snapshot: async () => ({ nodes: [] }),
276+
screenshot: async () => {},
277+
click: async () => {},
278+
fill: async () => {},
279+
typeText: async () => {},
280+
scroll: async () => {},
281+
...overrides,
282+
};
283+
}

src/daemon/request-platform-providers.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
AppleToolProvider,
1010
} from '../platforms/ios/tool-provider.ts';
1111
import type { LinuxToolProvider } from '../platforms/linux/tool-provider.ts';
12+
import type { WebProvider } from '../platforms/web/provider.ts';
1213
import { isApplePlatform, type DeviceInfo } from '../utils/device.ts';
1314
import type { AppLogProvider } from './app-log.ts';
1415
import { hasExplicitDeviceSelector } from './device-selector-intent.ts';
@@ -39,6 +40,8 @@ export type AppleToolProviderResolver = PlatformProviderResolver<
3940

4041
export type LinuxToolProviderResolver = PlatformProviderResolver<LinuxToolProvider | undefined>;
4142

43+
export type WebProviderResolver = PlatformProviderResolver<WebProvider | undefined>;
44+
4245
export type AppLogProviderResolver = PlatformProviderResolver<AppLogProvider | undefined>;
4346

4447
export type RecordingProviderResolver = PlatformProviderResolver<RecordingProvider | undefined>;
@@ -48,6 +51,7 @@ export type PlatformProviderResolvers = {
4851
appleRunnerProvider?: AppleRunnerProviderResolver;
4952
appleToolProvider?: AppleToolProviderResolver;
5053
linuxToolProvider?: LinuxToolProviderResolver;
54+
webProvider?: WebProviderResolver;
5155
appLogProvider?: AppLogProviderResolver;
5256
recordingProvider?: RecordingProviderResolver;
5357
};
@@ -85,6 +89,9 @@ type ResolvedRequestPlatformProviders = {
8589
linuxTool?: {
8690
provider?: LinuxToolProvider;
8791
};
92+
web?: {
93+
provider?: WebProvider;
94+
};
8895
appLog?: {
8996
provider?: AppLogProvider;
9097
};
@@ -184,6 +191,19 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [
184191
appendRequestProviderWrapper(wrappers, scopedProviders.linuxTool, withLinuxToolProvider);
185192
},
186193
},
194+
{
195+
resolverKey: 'webProvider',
196+
resolve(providers, context) {
197+
const webProvider = providers.webProvider;
198+
if (!webProvider || context.device.platform !== 'web') return {};
199+
return { web: { provider: webProvider(context) } };
200+
},
201+
async appendWrapper(scopedProviders, wrappers) {
202+
if (!scopedProviders.web?.provider) return;
203+
const { withWebProvider } = await import('../platforms/web/provider.ts');
204+
appendRequestProviderWrapper(wrappers, scopedProviders.web, withWebProvider);
205+
},
206+
},
187207
{
188208
resolverKey: 'appLogProvider',
189209
resolve(providers, context) {

src/platforms/web/provider.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { ScrollDirection } from '../../core/scroll-gesture.ts';
2+
import type { SessionSurface } from '../../core/session-surface.ts';
3+
import { AppError } from '../../utils/errors.ts';
4+
import { createScopedProvider } from '../../utils/scoped-provider.ts';
5+
import type { RawSnapshotNode } from '../../utils/snapshot.ts';
6+
7+
export type WebOpenOptions = {
8+
url?: string;
9+
};
10+
11+
export type WebScreenshotOptions = {
12+
fullscreen?: boolean;
13+
stabilize?: boolean;
14+
surface?: SessionSurface;
15+
};
16+
17+
export type WebSnapshotOptions = {
18+
interactiveOnly?: boolean;
19+
depth?: number;
20+
scope?: string;
21+
raw?: boolean;
22+
surface?: SessionSurface;
23+
};
24+
25+
export type WebSnapshotResult = {
26+
nodes: RawSnapshotNode[];
27+
truncated?: boolean;
28+
};
29+
30+
export type WebProvider = {
31+
open(target: string, options?: WebOpenOptions): Promise<void>;
32+
close(target?: string): Promise<void>;
33+
snapshot(options?: WebSnapshotOptions): Promise<WebSnapshotResult>;
34+
screenshot(outPath: string, options?: WebScreenshotOptions): Promise<void>;
35+
click(x: number, y: number): Promise<void>;
36+
fill(x: number, y: number, text: string, options?: { delayMs?: number }): Promise<void>;
37+
typeText(text: string, options?: { delayMs?: number }): Promise<void>;
38+
scroll(direction: ScrollDirection, options?: { amount?: number; pixels?: number }): Promise<void>;
39+
readText?(x: number, y: number): Promise<string>;
40+
};
41+
42+
const localWebProvider: WebProvider = {
43+
open: () => unsupportedLocalWebProvider(),
44+
close: () => unsupportedLocalWebProvider(),
45+
snapshot: () => unsupportedLocalWebProvider(),
46+
screenshot: () => unsupportedLocalWebProvider(),
47+
click: () => unsupportedLocalWebProvider(),
48+
fill: () => unsupportedLocalWebProvider(),
49+
typeText: () => unsupportedLocalWebProvider(),
50+
scroll: () => unsupportedLocalWebProvider(),
51+
};
52+
53+
const webProviderScope = createScopedProvider(localWebProvider);
54+
55+
export function resolveWebProvider(provider?: WebProvider): WebProvider {
56+
return webProviderScope.resolve(provider);
57+
}
58+
59+
export async function withWebProvider<T>(
60+
provider: WebProvider | undefined,
61+
fn: () => Promise<T>,
62+
): Promise<T> {
63+
return await webProviderScope.run(provider, fn);
64+
}
65+
66+
async function unsupportedLocalWebProvider(): Promise<never> {
67+
throw new AppError(
68+
'UNSUPPORTED_OPERATION',
69+
'Web automation requires a request-scoped web provider.',
70+
);
71+
}

0 commit comments

Comments
 (0)