Skip to content

Commit 40fb0e9

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

12 files changed

Lines changed: 849 additions & 23 deletions

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

0 commit comments

Comments
 (0)