Skip to content

Commit 2139438

Browse files
committed
feat: bridge web provider to agent-browser
1 parent 833479e commit 2139438

9 files changed

Lines changed: 822 additions & 22 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: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,11 @@ export async function handleCloseCommand(params: {
111111
}
112112
await stopSessionApplePerfCapture(session);
113113
await stopSessionAndroidNativePerfCapture(session);
114-
if (req.positionals && req.positionals.length > 0) {
114+
if (shouldDispatchPlatformClose(req, session)) {
115115
if (shouldStopAppleRunnerBeforeTargetedClose(session)) {
116116
await stopAppleRunnerForClose(session);
117117
}
118-
await dispatchCommand(session.device, 'close', req.positionals, req.flags?.out, {
118+
await dispatchCommand(session.device, 'close', req.positionals ?? [], req.flags?.out, {
119119
...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath),
120120
});
121121
await settleIosSimulator(session.device, IOS_SIMULATOR_POST_CLOSE_SETTLE_MS);
@@ -172,6 +172,14 @@ export async function handleCloseCommand(params: {
172172
return { ok: true, data: { session: session.name, ...successText(`Closed: ${session.name}`) } };
173173
}
174174

175+
function shouldDispatchPlatformClose(req: DaemonRequest, session: SessionState): boolean {
176+
return hasCloseTarget(req) || session.device.platform === 'web';
177+
}
178+
179+
function hasCloseTarget(req: DaemonRequest): boolean {
180+
return (req.positionals?.length ?? 0) > 0;
181+
}
182+
175183
async function closeWithoutSession(req: DaemonRequest, logPath: string): Promise<DaemonResponse> {
176184
if (!req.positionals || req.positionals.length === 0) {
177185
return errorResponse('SESSION_NOT_FOUND', 'No active session');

src/daemon/request-router.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
type RequestExecutionScope,
3434
} from './request-execution-scope.ts';
3535
import { canRunReplayScopedAction } from './daemon-command-registry.ts';
36+
import { createAgentBrowserWebProvider } from '../platforms/web/agent-browser-provider.ts';
3637

3738
// ---------------------------------------------------------------------------
3839
// Request handler API
@@ -135,7 +136,9 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
135136
appleRunnerProvider,
136137
appleToolProvider,
137138
linuxToolProvider,
138-
webProvider,
139+
webProvider:
140+
webProvider ??
141+
(shouldUseDefaultWebProvider(lockedScope) ? createDefaultWebProvider : undefined),
139142
appLogProvider,
140143
recordingProvider,
141144
},
@@ -199,6 +202,13 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
199202
return handleRequest;
200203
}
201204

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

0 commit comments

Comments
 (0)