Skip to content

Commit d064823

Browse files
committed
feat: bridge web provider to agent-browser
1 parent bd60330 commit d064823

8 files changed

Lines changed: 708 additions & 21 deletions

File tree

src/core/dispatch.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,12 @@ async function dispatchKnownCommand(
100100
return await handleOpenCommand(device, interactor, positionals, context);
101101
case 'close': {
102102
const app = positionals[0];
103-
if (!app) return { closed: 'session', ...successText('Closed session') };
103+
if (!app) {
104+
if (device.platform === 'web') {
105+
await interactor.close('');
106+
}
107+
return { closed: 'session', ...successText('Closed session') };
108+
}
104109
await interactor.close(app);
105110
return { app, ...successText(`Closed: ${app}`) };
106111
}

src/daemon/handlers/__tests__/session-close-shutdown.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,14 @@ import { handleSessionCommands } from '../session.ts';
4747
import { teardownSessionResources } from '../session-close.ts';
4848
import { shutdownSimulator } from '../../../platforms/ios/simulator.ts';
4949
import { runCmd } from '../../../utils/exec.ts';
50+
import { dispatchCommand } from '../../../core/dispatch.ts';
5051
import { cleanupAppleXctracePerfCapture } from '../../../platforms/ios/perf-xctrace.ts';
5152
import { cleanupAndroidNativePerfSession } from '../../../platforms/android/perf.ts';
53+
import { WEB_DESKTOP_DEVICE } from '../../../__tests__/test-utils/index.ts';
5254

5355
const mockShutdownSimulator = vi.mocked(shutdownSimulator);
5456
const mockRunCmd = vi.mocked(runCmd);
57+
const mockDispatchCommand = vi.mocked(dispatchCommand);
5558
const mockCleanupAppleXctracePerfCapture = vi.mocked(cleanupAppleXctracePerfCapture);
5659
const mockCleanupAndroidNativePerfSession = vi.mocked(cleanupAndroidNativePerfSession);
5760

@@ -345,6 +348,36 @@ test('close stops active Android native perf capture before deleting session', a
345348
expect(sessionStore.get(sessionName)).toBeUndefined();
346349
});
347350

351+
test('close dispatches web session cleanup without a positional target', async () => {
352+
const sessionStore = makeSessionStore();
353+
const sessionName = 'web-close-session';
354+
sessionStore.set(sessionName, makeSession(sessionName, WEB_DESKTOP_DEVICE));
355+
356+
const response = await handleSessionCommands({
357+
req: {
358+
token: 't',
359+
session: sessionName,
360+
command: 'close',
361+
positionals: [],
362+
flags: {},
363+
},
364+
sessionName,
365+
logPath: path.join(os.tmpdir(), 'daemon.log'),
366+
sessionStore,
367+
invoke: noopInvoke,
368+
});
369+
370+
expect(response?.ok).toBe(true);
371+
expect(mockDispatchCommand).toHaveBeenCalledWith(
372+
WEB_DESKTOP_DEVICE,
373+
'close',
374+
[],
375+
undefined,
376+
expect.objectContaining({ logPath: expect.stringContaining('daemon.log') }),
377+
);
378+
expect(sessionStore.get(sessionName)).toBeUndefined();
379+
});
380+
348381
test('daemon session teardown stops active Android native perf capture', async () => {
349382
const sessionName = 'android-active-native-perf-teardown-session';
350383
const activeCapture = {

src/daemon/handlers/session-close.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,13 @@ export async function handleCloseCommand(params: {
111111
}
112112
await stopSessionApplePerfCapture(session);
113113
await stopSessionAndroidNativePerfCapture(session);
114-
if (req.positionals && req.positionals.length > 0) {
114+
const shouldDispatchClose =
115+
(req.positionals && req.positionals.length > 0) || session.device.platform === 'web';
116+
if (shouldDispatchClose) {
115117
if (shouldStopAppleRunnerBeforeTargetedClose(session)) {
116118
await stopAppleRunnerForClose(session);
117119
}
118-
await dispatchCommand(session.device, 'close', req.positionals, req.flags?.out, {
120+
await dispatchCommand(session.device, 'close', req.positionals ?? [], req.flags?.out, {
119121
...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath),
120122
});
121123
await settleIosSimulator(session.device, IOS_SIMULATOR_POST_CLOSE_SETTLE_MS);

src/daemon/request-router.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type LinuxToolProviderResolver,
1515
type RequestPlatformProviderScope,
1616
type RecordingProviderResolver,
17+
type WebProviderResolver,
1718
withRequestPlatformProviderScope,
1819
} from './request-platform-providers.ts';
1920
import {
@@ -32,6 +33,7 @@ import {
3233
type RequestExecutionScope,
3334
} from './request-execution-scope.ts';
3435
import { canRunReplayScopedAction } from './daemon-command-registry.ts';
36+
import { createAgentBrowserWebProvider } from '../platforms/web/agent-browser-provider.ts';
3537

3638
// ---------------------------------------------------------------------------
3739
// Request handler API
@@ -46,6 +48,7 @@ export type RequestRouterDeps = {
4648
appleRunnerProvider?: AppleRunnerProviderResolver;
4749
appleToolProvider?: AppleToolProviderResolver;
4850
linuxToolProvider?: LinuxToolProviderResolver;
51+
webProvider?: WebProviderResolver;
4952
appLogProvider?: AppLogProviderResolver;
5053
recordingProvider?: RecordingProviderResolver;
5154
deviceInventoryProvider?: DeviceInventoryProvider;
@@ -64,6 +67,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
6467
appleRunnerProvider,
6568
appleToolProvider,
6669
linuxToolProvider,
70+
webProvider,
6771
appLogProvider,
6872
recordingProvider,
6973
deviceInventoryProvider,
@@ -132,6 +136,9 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
132136
appleRunnerProvider,
133137
appleToolProvider,
134138
linuxToolProvider,
139+
webProvider:
140+
webProvider ??
141+
(shouldUseDefaultWebProvider(lockedScope) ? createDefaultWebProvider : undefined),
135142
appLogProvider,
136143
recordingProvider,
137144
},
@@ -195,6 +202,13 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
195202
return handleRequest;
196203
}
197204

205+
const createDefaultWebProvider: WebProviderResolver = ({ req, session }) =>
206+
createAgentBrowserWebProvider({ session: session?.name ?? req.session });
207+
208+
function shouldUseDefaultWebProvider(scope: LockedRequestScope): boolean {
209+
return scope.existingSession?.device.platform === 'web' || scope.req.flags?.platform === 'web';
210+
}
211+
198212
function unauthorizedResponse(): DaemonResponse {
199213
return {
200214
ok: false,
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { createAgentBrowserWebProvider } from './agent-browser-provider.ts';
4+
import { withCommandExecutorOverride, type ExecResult } from '../../utils/exec.ts';
5+
import { AppError } from '../../utils/errors.ts';
6+
7+
type AgentBrowserCall = {
8+
cmd: string;
9+
args: string[];
10+
};
11+
12+
test('agent-browser provider maps supported operations to session-scoped JSON commands', async () => {
13+
const calls: AgentBrowserCall[] = [];
14+
const provider = createAgentBrowserWebProvider({ session: 'web-session' });
15+
16+
await withCommandExecutorOverride(recordingExecutor(calls), async () => {
17+
await provider.open('https://example.test');
18+
await provider.screenshot('/tmp/page.png', { fullscreen: true });
19+
await provider.click(10.4, 20.6);
20+
await provider.fill(11, 22, 'Ada');
21+
await provider.typeText('hello');
22+
await provider.scroll('down', { pixels: 400 });
23+
await provider.close();
24+
});
25+
26+
assert.deepEqual(
27+
calls.map((call) => call.args),
28+
[
29+
['open', 'https://example.test', '--json', '--session', 'web-session'],
30+
['screenshot', '--full', '/tmp/page.png', '--json', '--session', 'web-session'],
31+
['mouse', 'move', '10', '21', '--json', '--session', 'web-session'],
32+
['mouse', 'down', '--json', '--session', 'web-session'],
33+
['mouse', 'up', '--json', '--session', 'web-session'],
34+
['mouse', 'move', '11', '22', '--json', '--session', 'web-session'],
35+
['mouse', 'down', '--json', '--session', 'web-session'],
36+
['mouse', 'up', '--json', '--session', 'web-session'],
37+
['press', 'Control+a', '--json', '--session', 'web-session'],
38+
['keyboard', 'type', 'Ada', '--json', '--session', 'web-session'],
39+
['keyboard', 'type', 'hello', '--json', '--session', 'web-session'],
40+
['scroll', 'down', '400', '--json', '--session', 'web-session'],
41+
['close', '--json', '--session', 'web-session'],
42+
],
43+
);
44+
});
45+
46+
test('agent-browser provider normalizes snapshot refs, labels, values, parents, and rects', async () => {
47+
const calls: AgentBrowserCall[] = [];
48+
const provider = createAgentBrowserWebProvider({ session: 'web-session' });
49+
50+
const snapshot = await withCommandExecutorOverride(
51+
async (cmd, args) => {
52+
calls.push({ cmd, args });
53+
if (args[0] === 'snapshot') {
54+
return jsonResult({
55+
success: true,
56+
data: {
57+
refs: {
58+
e1: { role: 'heading', name: 'Welcome' },
59+
e2: { role: 'textbox', value: 'Ada' },
60+
e3: { role: 'button', name: 'Save' },
61+
e4: { role: 'link', name: 'Docs' },
62+
},
63+
snapshot: [
64+
'heading "Welcome" [ref=e1]',
65+
' textbox "Name" value="Ada" [ref=e2]',
66+
' button "Save" [ref=e3]',
67+
' link "Docs" [ref=e4]',
68+
].join('\n'),
69+
truncated: false,
70+
},
71+
});
72+
}
73+
if (args.slice(0, 3).join(' ') === 'get box @e3') {
74+
return jsonResult({ success: false, error: 'No box for element' });
75+
}
76+
if (args[0] === 'get' && args[1] === 'box') {
77+
const ref = args[2];
78+
const offset = ref === '@e1' ? 0 : ref === '@e2' ? 10 : 30;
79+
return jsonResult({
80+
success: true,
81+
data: { x: offset + 1, y: offset + 2, width: 100, height: 20 },
82+
});
83+
}
84+
return jsonResult({ success: true, data: {} });
85+
},
86+
async () => await provider.snapshot({ interactiveOnly: true, depth: 4, scope: '#main' }),
87+
);
88+
89+
assert.deepEqual(calls[0]?.args, [
90+
'snapshot',
91+
'--interactive',
92+
'--compact',
93+
'--depth',
94+
'4',
95+
'--selector',
96+
'#main',
97+
'--json',
98+
'--session',
99+
'web-session',
100+
]);
101+
assert.deepEqual(snapshot.nodes, [
102+
{
103+
index: 0,
104+
role: 'heading',
105+
label: 'Welcome',
106+
value: undefined,
107+
depth: 0,
108+
enabled: undefined,
109+
focused: undefined,
110+
rect: { x: 1, y: 2, width: 100, height: 20 },
111+
},
112+
{
113+
index: 1,
114+
role: 'textbox',
115+
label: 'Name',
116+
value: 'Ada',
117+
depth: 1,
118+
enabled: undefined,
119+
focused: undefined,
120+
parentIndex: 0,
121+
rect: { x: 11, y: 12, width: 100, height: 20 },
122+
},
123+
{
124+
index: 2,
125+
role: 'button',
126+
label: 'Save',
127+
value: undefined,
128+
depth: 1,
129+
enabled: undefined,
130+
focused: undefined,
131+
parentIndex: 0,
132+
},
133+
{
134+
index: 3,
135+
role: 'link',
136+
label: 'Docs',
137+
value: undefined,
138+
depth: 1,
139+
enabled: undefined,
140+
focused: undefined,
141+
parentIndex: 0,
142+
rect: { x: 31, y: 32, width: 100, height: 20 },
143+
},
144+
]);
145+
});
146+
147+
test('agent-browser provider surfaces stale ref failures during snapshot geometry lookup', async () => {
148+
const provider = createAgentBrowserWebProvider({ session: 'web-session' });
149+
150+
await assert.rejects(
151+
() =>
152+
withCommandExecutorOverride(
153+
async (_cmd, args) => {
154+
if (args[0] === 'snapshot') {
155+
return jsonResult({
156+
success: true,
157+
data: {
158+
refs: { e1: { role: 'button', name: 'Save' } },
159+
snapshot: 'button "Save" [ref=e1]',
160+
},
161+
});
162+
}
163+
return jsonResult({ success: false, error: 'Stale ref @e1' });
164+
},
165+
async () => await provider.snapshot(),
166+
),
167+
(error: unknown) =>
168+
error instanceof AppError &&
169+
error.code === 'COMMAND_FAILED' &&
170+
error.message === 'Stale ref @e1',
171+
);
172+
});
173+
174+
test('agent-browser provider adds doctor guidance for missing binary and invalid JSON', async () => {
175+
const provider = createAgentBrowserWebProvider();
176+
177+
await assert.rejects(
178+
() =>
179+
withCommandExecutorOverride(
180+
async () => {
181+
throw new AppError('TOOL_MISSING', 'agent-browser not found');
182+
},
183+
async () => await provider.open('https://example.test'),
184+
),
185+
(error: unknown) =>
186+
error instanceof AppError &&
187+
error.code === 'TOOL_MISSING' &&
188+
error.details?.hint ===
189+
'Install agent-browser and run `agent-browser doctor --offline --quick` to verify the local browser setup.',
190+
);
191+
192+
await assert.rejects(
193+
() =>
194+
withCommandExecutorOverride(
195+
async () => ({ stdout: 'not-json', stderr: '', exitCode: 0 }),
196+
async () => await provider.open('https://example.test'),
197+
),
198+
(error: unknown) =>
199+
error instanceof AppError &&
200+
error.code === 'COMMAND_FAILED' &&
201+
error.message === 'agent-browser returned invalid JSON' &&
202+
typeof error.details?.hint === 'string',
203+
);
204+
});
205+
206+
function recordingExecutor(calls: AgentBrowserCall[]) {
207+
return async (cmd: string, args: string[]): Promise<ExecResult> => {
208+
calls.push({ cmd, args });
209+
return jsonResult({ success: true, data: {} });
210+
};
211+
}
212+
213+
function jsonResult(value: unknown): ExecResult {
214+
return { stdout: JSON.stringify(value), stderr: '', exitCode: 0 };
215+
}

0 commit comments

Comments
 (0)