From 6a9d6aa09fe5c5e6f081c3c3df2615ca841b123f Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 11 Sep 2026 18:14:26 +0000 Subject: [PATCH 1/8] feat(cli): restore cursor overlay through page.evaluate --- packages/cli/src/lib/driver/commands/mouse.ts | 20 +++++ .../cli/src/lib/driver/commands/runtime.ts | 10 ++- .../src/lib/driver/commands/unavailable.ts | 9 -- packages/cli/src/lib/driver/cursor-overlay.ts | 65 ++++++++++++++ .../cli/src/lib/driver/session-manager.ts | 10 +++ packages/cli/tests/driver-commands.test.ts | 85 ++++++++++++++++++- 6 files changed, 185 insertions(+), 14 deletions(-) delete mode 100644 packages/cli/src/lib/driver/commands/unavailable.ts create mode 100644 packages/cli/src/lib/driver/cursor-overlay.ts diff --git a/packages/cli/src/lib/driver/commands/mouse.ts b/packages/cli/src/lib/driver/commands/mouse.ts index 62268b172f..cb0c309562 100644 --- a/packages/cli/src/lib/driver/commands/mouse.ts +++ b/packages/cli/src/lib/driver/commands/mouse.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { updateCursorOverlayPosition } from "../cursor-overlay.js"; +import type { DriverPage, DriverSessionManager } from "../session-manager.js"; import type { DriverCommandHandlers } from "./types.js"; const ButtonSchema = z.enum(["left", "right", "middle"]).optional(); @@ -17,6 +19,7 @@ export const mouseHandlers: DriverCommandHandlers = { .parse(params); assertXPathUnavailable(returnXPath); const page = await manager.activePage(); + await positionCursorOverlay(manager, page, x, y); await page.click(x, y, { ...(button === undefined ? {} : { button }), ...(clickCount === undefined ? {} : { clickCount }), @@ -34,6 +37,7 @@ export const mouseHandlers: DriverCommandHandlers = { .parse(params); assertXPathUnavailable(returnXPath); const page = await manager.activePage(); + await positionCursorOverlay(manager, page, x, y); await page.hover(x, y); return { hovered: true }; }, @@ -50,6 +54,7 @@ export const mouseHandlers: DriverCommandHandlers = { .parse(params); assertXPathUnavailable(returnXPath); const page = await manager.activePage(); + await positionCursorOverlay(manager, page, x, y); await page.scroll(x, y, deltaX, deltaY); return { scrolled: true }; }, @@ -69,15 +74,30 @@ export const mouseHandlers: DriverCommandHandlers = { .parse(params); assertXPathUnavailable(returnXPath); const page = await manager.activePage(); + await positionCursorOverlay(manager, page, fromX, fromY); await page.dragAndDrop(fromX, fromY, toX, toY, { ...(button === undefined ? {} : { button }), ...(delay === undefined ? {} : { delay }), ...(steps === undefined ? {} : { steps }), }); + // A successful drag may navigate and destroy the old execution context. + // The final marker position is visual-only, so do not turn that race into a + // reported drag failure. + await positionCursorOverlay(manager, page, toX, toY).catch(() => undefined); return { dragged: true }; }, }; +async function positionCursorOverlay( + manager: DriverSessionManager, + page: DriverPage, + x: number, + y: number, +): Promise { + if (!manager.isCursorOverlayEnabled(page)) return; + await page.evaluate(updateCursorOverlayPosition, { x, y }); +} + function assertXPathUnavailable(returnXPath: boolean | undefined): void { if (returnXPath) { throw new Error("Coordinate XPath lookup is not exposed by Stagehand V4"); diff --git a/packages/cli/src/lib/driver/commands/runtime.ts b/packages/cli/src/lib/driver/commands/runtime.ts index 9f06c0fdd9..54dbe3f4f1 100644 --- a/packages/cli/src/lib/driver/commands/runtime.ts +++ b/packages/cli/src/lib/driver/commands/runtime.ts @@ -2,8 +2,8 @@ import { promises as fs } from "node:fs"; import { z } from "zod"; +import { CURSOR_OVERLAY_SCRIPT } from "../cursor-overlay.js"; import type { DriverCommandHandlers } from "./types.js"; -import { unavailableCursorOverlay } from "./unavailable.js"; export const runtimeHandlers: DriverCommandHandlers = { async screenshot(manager, params) { @@ -89,7 +89,13 @@ export const runtimeHandlers: DriverCommandHandlers = { return { waited: true }; }, - cursor: unavailableCursorOverlay, + async cursor(manager) { + const page = await manager.activePage(); + await page.addInitScript(CURSOR_OVERLAY_SCRIPT); + await page.evaluate(CURSOR_OVERLAY_SCRIPT); + manager.markCursorOverlayEnabled(page); + return { enabled: true }; + }, }; function parseTimeoutMs(value: string | undefined): number { diff --git a/packages/cli/src/lib/driver/commands/unavailable.ts b/packages/cli/src/lib/driver/commands/unavailable.ts deleted file mode 100644 index 6f1f3d86e0..0000000000 --- a/packages/cli/src/lib/driver/commands/unavailable.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { DriverError } from "../errors.js"; -import type { DriverCommandHandler } from "./types.js"; - -export const unavailableCursorOverlay: DriverCommandHandler = async () => { - throw new DriverError( - "The visible cursor overlay has not been restored in this Stagehand V4 stack layer.", - { code: "cursor_overlay_unavailable" }, - ); -}; diff --git a/packages/cli/src/lib/driver/cursor-overlay.ts b/packages/cli/src/lib/driver/cursor-overlay.ts new file mode 100644 index 0000000000..de966a9d2d --- /dev/null +++ b/packages/cli/src/lib/driver/cursor-overlay.ts @@ -0,0 +1,65 @@ +export const CURSOR_OVERLAY_SCRIPT = `(() => { + if (globalThis !== globalThis.top) return; + + const cursorId = "__browse_cursor_overlay__"; + const ensureCursor = () => { + const existing = document.getElementById(cursorId); + if (existing instanceof HTMLDivElement) return existing; + + const root = document.documentElement || document.body; + if (!root) return null; + + const cursor = document.createElement("div"); + cursor.id = cursorId; + cursor.setAttribute("aria-hidden", "true"); + Object.assign(cursor.style, { + contain: "layout style paint", + height: "24px", + left: "0px", + mixBlendMode: "normal", + pointerEvents: "none", + position: "fixed", + top: "0px", + userSelect: "none", + width: "16px", + willChange: "left,top", + zIndex: "2147483647", + }); + cursor.innerHTML = + ''; + root.appendChild(cursor); + return cursor; + }; + + const moveCursor = (x, y) => { + const cursor = ensureCursor(); + if (!cursor) return; + cursor.style.left = Math.max(0, x) + "px"; + cursor.style.top = Math.max(0, y) + "px"; + }; + + globalThis.__browseMoveCursorOverlay__ = moveCursor; + ensureCursor(); + if (!globalThis.__browseCursorOverlayListenerInstalled__) { + document.addEventListener( + "mousemove", + (event) => { + moveCursor(event.clientX, event.clientY); + }, + { capture: true }, + ); + globalThis.__browseCursorOverlayListenerInstalled__ = true; + } +})()`; + +export function updateCursorOverlayPosition(position: { + x: number; + y: number; +}): void { + const moveCursor = ( + globalThis as typeof globalThis & { + __browseMoveCursorOverlay__?: (x: number, y: number) => void; + } + ).__browseMoveCursorOverlay__; + moveCursor?.(position.x, position.y); +} diff --git a/packages/cli/src/lib/driver/session-manager.ts b/packages/cli/src/lib/driver/session-manager.ts index 7f30ae3951..4e9381b70e 100644 --- a/packages/cli/src/lib/driver/session-manager.ts +++ b/packages/cli/src/lib/driver/session-manager.ts @@ -80,6 +80,7 @@ export class DriverSessionManager { private browserbaseIdentityValue: BrowserbaseIdentity = {}; private consecutiveInitFailures = 0; private context: DriverContext | null = null; + private cursorOverlayPageIds = new Set(); private lastForwardedEnvSignature: string | null = null; private pendingEnv: ForwardedEnv | undefined; private initFailure: InitFailure | null = null; @@ -207,6 +208,7 @@ export class DriverSessionManager { this.stagehand = null; this.browser = null; this.context = null; + this.cursorOverlayPageIds.clear(); this.browserbaseIdentityValue = {}; this.initFailure = null; this.consecutiveInitFailures = 0; @@ -223,6 +225,14 @@ export class DriverSessionManager { return resolveCachedSelector(selector, this.refMaps); } + markCursorOverlayEnabled(page: DriverPage): void { + this.cursorOverlayPageIds.add(page.pageId); + } + + isCursorOverlayEnabled(page: DriverPage): boolean { + return this.cursorOverlayPageIds.has(page.pageId); + } + setRefMaps(refMaps: RefMaps): void { this.refMaps = refMaps; } diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index 58b9d6e55b..6861b6d2b3 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -326,6 +326,7 @@ describe("driver commands", () => { }; const manager = { activePage: vi.fn(async () => page), + isCursorOverlayEnabled: vi.fn(() => false), } as unknown as Parameters< NonNullable<(typeof mouseHandlers)["mouse.click"]> >[0]; @@ -385,6 +386,7 @@ describe("driver commands", () => { }; const manager = { activePage: vi.fn(async () => page), + isCursorOverlayEnabled: vi.fn(() => false), } as unknown as Parameters< NonNullable<(typeof mouseHandlers)["mouse.click"]> >[0]; @@ -410,6 +412,34 @@ describe("driver commands", () => { expect(page.dragAndDrop).toHaveBeenCalledWith(70, 80, 90, 100, {}); }); + it("keeps a successful drag successful when navigation races the final cursor update", async () => { + const page = { + dragAndDrop: vi.fn(), + evaluate: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("Execution context was destroyed")), + }; + const manager = { + activePage: vi.fn(async () => page), + isCursorOverlayEnabled: vi.fn(() => true), + } as unknown as Parameters< + NonNullable<(typeof mouseHandlers)["mouse.drag"]> + >[0]; + + await expect( + mouseHandlers["mouse.drag"]!(manager, { + fromX: 10, + fromY: 20, + toX: 30, + toY: 40, + }), + ).resolves.toEqual({ dragged: true }); + + expect(page.dragAndDrop).toHaveBeenCalledWith(10, 20, 30, 40, {}); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); + it("fails explicitly for the V4 coordinate XPath capability", async () => { const manager = {} as Parameters< NonNullable<(typeof mouseHandlers)["mouse.click"]> @@ -447,10 +477,59 @@ describe("driver commands", () => { expect(network.enable).toHaveBeenCalledWith(page); }); - it("keeps cursor as an explicit capability gap", async () => { + it("installs the CLI-owned cursor overlay", async () => { + const page = { + addInitScript: vi.fn(), + evaluate: vi.fn(), + pageId: "page-1", + }; + const manager = { + activePage: vi.fn(async () => page), + markCursorOverlayEnabled: vi.fn(), + } as unknown as Parameters< + NonNullable<(typeof runtimeHandlers)["cursor"]> + >[0]; + + await expect(runtimeHandlers.cursor!(manager, {})).resolves.toEqual({ + enabled: true, + }); + expect(page.addInitScript).toHaveBeenCalledOnce(); + expect(page.evaluate).toHaveBeenCalledOnce(); + expect(page.addInitScript).toHaveBeenCalledWith( + page.evaluate.mock.calls[0]?.[0], + ); + expect(manager.markCursorOverlayEnabled).toHaveBeenCalledWith(page); + const cursorInstaller = page.evaluate.mock.calls[0]?.[0]; + expect(cursorInstaller).toEqual(expect.any(String)); + expect(cursorInstaller).toContain("__browse_cursor_overlay__"); + expect(cursorInstaller).toContain("globalThis !== globalThis.top"); + expect(cursorInstaller).toContain('"mousemove"'); + }); + + it("moves an enabled overlay from coordinate input before iframe-targeted actions", async () => { + const page = { + evaluate: vi.fn(), + hover: vi.fn(), + pageId: "page-1", + }; + const manager = { + activePage: vi.fn(async () => page), + isCursorOverlayEnabled: vi.fn(() => true), + } as unknown as Parameters< + NonNullable<(typeof mouseHandlers)["mouse.hover"]> + >[0]; + await expect( - runtimeHandlers.cursor!({} as never, {}), - ).rejects.toMatchObject({ code: "cursor_overlay_unavailable" }); + mouseHandlers["mouse.hover"]!(manager, { x: 30, y: 40 }), + ).resolves.toEqual({ hovered: true }); + + expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function), { + x: 30, + y: 40, + }); + expect(page.evaluate.mock.invocationCallOrder[0]).toBeLessThan( + page.hover.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); }); it("selects a remaining tab after closing the active tab", async () => { From 7861d65e18721df9e625c378a39f4e09327f0d21 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 11 Sep 2026 18:14:47 +0000 Subject: [PATCH 2/8] fix(cli): capture network traffic through a CDP sidecar --- .../cli/src/lib/driver/commands/network.ts | 3 +- .../cli/src/lib/driver/network-capture.ts | 151 ++++++--- .../cli/src/lib/driver/network-cdp-sidecar.ts | 307 ++++++++++++++++++ .../cli/src/lib/driver/session-manager.ts | 14 +- packages/cli/tests/driver-commands.test.ts | 16 +- packages/cli/tests/network-capture.test.ts | 239 ++++++++++++++ .../cli/tests/network-cdp-sidecar.test.ts | 91 ++++++ 7 files changed, 764 insertions(+), 57 deletions(-) create mode 100644 packages/cli/src/lib/driver/network-cdp-sidecar.ts create mode 100644 packages/cli/tests/network-capture.test.ts create mode 100644 packages/cli/tests/network-cdp-sidecar.test.ts diff --git a/packages/cli/src/lib/driver/commands/network.ts b/packages/cli/src/lib/driver/commands/network.ts index 5887f132c9..9e52c0f727 100644 --- a/packages/cli/src/lib/driver/commands/network.ts +++ b/packages/cli/src/lib/driver/commands/network.ts @@ -3,7 +3,8 @@ import type { DriverCommandHandlers } from "./types.js"; export const networkHandlers: DriverCommandHandlers = { async "network.on"(manager) { const page = await manager.activePage(); - return manager.network.enable(page); + const websocketUrl = await manager.networkWebSocketDebuggerUrl(); + return manager.network.enable(page, websocketUrl); }, async "network.off"(manager) { diff --git a/packages/cli/src/lib/driver/network-capture.ts b/packages/cli/src/lib/driver/network-capture.ts index 921f58afb2..7da01b35dd 100644 --- a/packages/cli/src/lib/driver/network-capture.ts +++ b/packages/cli/src/lib/driver/network-capture.ts @@ -7,7 +7,10 @@ import { getNetworkDir, writePrivateFile, } from "./daemon/paths.js"; -import { DriverError } from "./errors.js"; +import { + NetworkCdpSidecar, + type NetworkCdpSession, +} from "./network-cdp-sidecar.js"; interface PendingRequest { body: string | null; @@ -26,71 +29,102 @@ interface ResponseMetadata { statusText: string; } -type CdpSession = { - off?: (event: string, listener: (...args: unknown[]) => void) => void; - on: (event: string, listener: (...args: unknown[]) => void) => void; - send: ( - method: string, - params?: Record, - ) => Promise; +type StagehandV4Page = { + pageId: string; }; +/** + * The V3 Browse network writer, adapted only at the CDP-session boundary. + * Keeping request correlation and the on-disk request/response schema here + * unchanged gives the V4 CLI observable parity without adding a public + * Stagehand network-event API. + */ export class NetworkCapture { - private cdpSession: CdpSession | null = null; + private cdpSession: NetworkCdpSession | null = null; private counter = 0; private enabled = false; + private lifecycle: Promise = Promise.resolve(); private readonly pendingRequests = new Map(); private readonly requestDirs = new Map>(); private readonly requestStartTimes = new Map(); private readonly responseMetadata = new Map(); - private readonly listeners: Array<[string, (...args: unknown[]) => void]> = - []; + private readonly listeners: Array<[string, (params: unknown) => void]> = []; private networkDir: string | null = null; - constructor(private readonly session: string) {} + constructor( + private readonly session: string, + private readonly sidecar = new NetworkCdpSidecar(), + ) {} async enable( - page: unknown, + page: StagehandV4Page, + browserWebSocketDebuggerUrl: string, ): Promise<{ alreadyEnabled?: boolean; enabled: true; path: string }> { - if (this.enabled && this.networkDir) { + return this.runLifecycle(() => + this.enableNow(page, browserWebSocketDebuggerUrl), + ); + } + + async disable(): Promise<{ + alreadyDisabled?: boolean; + enabled: false; + path: string | null; + }> { + return this.runLifecycle(() => this.disableNow()); + } + + private async enableNow( + page: StagehandV4Page, + browserWebSocketDebuggerUrl: string, + ): Promise<{ alreadyEnabled?: boolean; enabled: true; path: string }> { + if (this.enabled && this.networkDir && this.cdpSession?.connected) { return { alreadyEnabled: true, enabled: true, path: this.networkDir }; } - - const cdpSession = await this.networkCdpSession(page); + if (this.enabled) await this.disableNow(); await ensureRuntimeDir(); this.networkDir = getNetworkDir(this.session); await ensurePrivateDir(this.networkDir); - this.counter = 0; + this.counter = await nextRequestCounter(this.networkDir); this.pendingRequests.clear(); this.requestDirs.clear(); this.requestStartTimes.clear(); this.responseMetadata.clear(); + const cdpSession = await this.sidecar.attach( + browserWebSocketDebuggerUrl, + page.pageId, + ); this.cdpSession = cdpSession; - await cdpSession.send("Network.enable", { - maxResourceBufferSize: 5_000_000, - maxTotalBufferSize: 10_000_000, - }); + try { + await cdpSession.send("Network.enable", { + maxResourceBufferSize: 5_000_000, + maxTotalBufferSize: 10_000_000, + }); - this.addListener("Network.requestWillBeSent", (params) => { - void this.handleRequestWillBeSent(params); - }); - this.addListener("Network.responseReceived", (params) => { - this.handleResponseReceived(params); - }); - this.addListener("Network.loadingFinished", (params) => { - void this.handleLoadingFinished(params); - }); - this.addListener("Network.loadingFailed", (params) => { - void this.handleLoadingFailed(params); - }); + this.addListener("Network.requestWillBeSent", (params) => { + void this.handleRequestWillBeSent(params); + }); + this.addListener("Network.responseReceived", (params) => { + this.handleResponseReceived(params); + }); + this.addListener("Network.loadingFinished", (params) => { + void this.handleLoadingFinished(params); + }); + this.addListener("Network.loadingFailed", (params) => { + void this.handleLoadingFailed(params); + }); + } catch (error) { + this.cdpSession = null; + await cdpSession.detach().catch(() => undefined); + throw error; + } this.enabled = true; return { enabled: true, path: this.networkDir }; } - async disable(): Promise<{ + private async disableNow(): Promise<{ alreadyDisabled?: boolean; enabled: false; path: string | null; @@ -99,20 +133,36 @@ export class NetworkCapture { return { alreadyDisabled: true, enabled: false, path: this.networkDir }; } + const cdpSession = this.cdpSession; + this.enabled = false; for (const [event, listener] of this.listeners) { - this.cdpSession?.off?.(event, listener); + cdpSession?.off(event, listener); } this.listeners.length = 0; - await this.cdpSession?.send("Network.disable").catch(() => undefined); + await cdpSession?.send("Network.disable").catch(() => undefined); + await cdpSession?.detach().catch(() => undefined); this.cdpSession = null; - this.enabled = false; return { enabled: false, path: this.networkDir }; } + private runLifecycle(operation: () => Promise): Promise { + const result = this.lifecycle.then(operation, operation); + this.lifecycle = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async close(): Promise { + await this.disable().catch(() => undefined); + this.sidecar.close(); + } + path(): { enabled: boolean; path: string } { return { - enabled: this.enabled, + enabled: this.enabled && (this.cdpSession?.connected ?? false), path: this.networkDir ?? getNetworkDir(this.session), }; } @@ -146,20 +196,12 @@ export class NetworkCapture { private addListener( event: string, - listener: (...args: unknown[]) => void, + listener: (params: unknown) => void, ): void { this.cdpSession?.on(event, listener); this.listeners.push([event, listener]); } - private async networkCdpSession(page: unknown): Promise { - void page; - throw new DriverError( - "Network capture is not available in this Stagehand V4 runtime. Apply the CLI CDP sidecar fast-follow to restore `browse network on`.", - { code: "network_capture_unavailable" }, - ); - } - private handleRequestWillBeSent(params: unknown): void { if (!this.enabled || !this.networkDir) return; const event = params as { @@ -330,6 +372,21 @@ function getRequestDirName( } } +async function nextRequestCounter(networkDir: string): Promise { + const entries = await fs.readdir(networkDir, { withFileTypes: true }); + let next = 0; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const match = /^(\d+)-/u.exec(entry.name); + if (!match) continue; + const rawCounter = match[1]; + if (!rawCounter) continue; + const counter = Number.parseInt(rawCounter, 10); + if (Number.isSafeInteger(counter)) next = Math.max(next, counter + 1); + } + return next; +} + function sanitizeForFilename(value: string, maxLen: number): string { return value .replace(/[^a-zA-Z0-9.-]/g, "-") diff --git a/packages/cli/src/lib/driver/network-cdp-sidecar.ts b/packages/cli/src/lib/driver/network-cdp-sidecar.ts new file mode 100644 index 0000000000..76b4ce4729 --- /dev/null +++ b/packages/cli/src/lib/driver/network-cdp-sidecar.ts @@ -0,0 +1,307 @@ +import WebSocket from "ws"; + +import { DriverError } from "./errors.js"; + +type CdpEventListener = (params: unknown) => void; + +interface CdpMessage { + error?: { code: number; message: string }; + id?: number; + method?: string; + params?: unknown; + result?: unknown; + sessionId?: string; +} + +interface PendingCommand { + method: string; + reject: (error: Error) => void; + resolve: (result: unknown) => void; +} + +export interface NetworkCdpSession { + readonly connected: boolean; + detach(): Promise; + off(event: string, listener: CdpEventListener): void; + on(event: string, listener: CdpEventListener): void; + send( + method: string, + params?: Record, + ): Promise; +} + +export type NetworkCdpWebSocketFactory = (url: string) => WebSocket; + +/** + * A CLI-owned browser-level CDP connection used only for V3-compatible + * network capture. The connection is intentionally kept alive across + * `network off`/`network on`: Browserbase treats closing an auxiliary browser + * WebSocket as a browser-session disconnect. It is closed only with the Browse + * driver session. + */ +export class NetworkCdpSidecar { + private connecting: Promise | null = null; + private nextId = 1; + private readonly pending = new Map(); + private readonly sessions = new Map< + string, + Map> + >(); + private socket: WebSocket | null = null; + private websocketUrl: string | null = null; + + constructor( + private readonly createWebSocket: NetworkCdpWebSocketFactory = (url) => + new WebSocket(url), + ) {} + + async attach( + websocketUrl: string, + targetId: string, + ): Promise { + await this.ensureConnected(websocketUrl); + const { sessionId } = await this.sendCommand<{ sessionId: string }>( + "Target.attachToTarget", + { flatten: true, targetId }, + ); + this.sessions.set(sessionId, new Map()); + return new AttachedNetworkCdpSession(this, sessionId); + } + + close(): void { + const socket = this.socket; + this.disconnect( + socket, + new DriverError("Network capture CDP sidecar closed.", { + code: "network_sidecar_closed", + }), + ); + if ( + socket && + (socket.readyState === WebSocket.OPEN || + socket.readyState === WebSocket.CONNECTING) + ) { + socket.close(); + } + } + + hasSession(sessionId: string): boolean { + return ( + this.socket?.readyState === WebSocket.OPEN && this.sessions.has(sessionId) + ); + } + + on(sessionId: string, event: string, listener: CdpEventListener): void { + const listeners = this.sessions.get(sessionId); + if (!listeners) return; + const eventListeners = listeners.get(event) ?? new Set(); + eventListeners.add(listener); + listeners.set(event, eventListeners); + } + + off(sessionId: string, event: string, listener: CdpEventListener): void { + const listeners = this.sessions.get(sessionId); + const eventListeners = listeners?.get(event); + eventListeners?.delete(listener); + if (eventListeners?.size === 0) listeners?.delete(event); + } + + async sendToSession( + sessionId: string, + method: string, + params: Record = {}, + ): Promise { + if (!this.sessions.has(sessionId)) { + throw new DriverError("Network capture CDP session is detached.", { + code: "network_sidecar_detached", + }); + } + return this.sendCommand(method, params, sessionId); + } + + async detach(sessionId: string): Promise { + if (!this.sessions.has(sessionId)) return; + this.sessions.delete(sessionId); + await this.sendCommand("Target.detachFromTarget", { sessionId }).catch( + () => undefined, + ); + } + + private async ensureConnected(websocketUrl: string): Promise { + if (this.socket?.readyState === WebSocket.OPEN) { + if (this.websocketUrl !== websocketUrl) { + throw new DriverError( + "The browser CDP endpoint changed during the Browse session.", + { code: "network_sidecar_endpoint_changed" }, + ); + } + return; + } + if (this.connecting) { + await this.connecting; + return; + } + + const connecting = this.open(websocketUrl); + this.connecting = connecting; + try { + await connecting; + } finally { + if (this.connecting === connecting) this.connecting = null; + } + } + + private async open(websocketUrl: string): Promise { + let socket: WebSocket; + try { + socket = this.createWebSocket(websocketUrl); + } catch (error) { + throw connectionError(error); + } + this.socket = socket; + this.websocketUrl = websocketUrl; + + await new Promise((resolve, reject) => { + let opened = false; + socket.on("message", (raw: WebSocket.RawData) => { + this.handleMessage(socket, raw); + }); + socket.on("error", (error: Error) => { + const failure = connectionError(error); + this.disconnect(socket, failure); + if (!opened) reject(failure); + }); + socket.on("close", () => { + const error = new DriverError( + "Network capture CDP sidecar disconnected.", + { code: "network_sidecar_disconnected" }, + ); + this.disconnect(socket, error); + if (!opened) reject(connectionError(error)); + }); + socket.once("open", () => { + opened = true; + resolve(); + }); + }); + } + + private sendCommand( + method: string, + params: Record = {}, + sessionId?: string, + ): Promise { + const socket = this.socket; + if (!socket || socket.readyState !== WebSocket.OPEN) { + return Promise.reject( + new DriverError("Network capture CDP sidecar is not connected.", { + code: "network_sidecar_disconnected", + }), + ); + } + + const id = this.nextId++; + const message = sessionId + ? { id, method, params, sessionId } + : { id, method, params }; + return new Promise((resolve, reject) => { + this.pending.set(id, { + method, + reject, + resolve: (result) => resolve(result as T), + }); + try { + socket.send(JSON.stringify(message)); + } catch (error) { + this.pending.delete(id); + reject(commandError(method, error)); + } + }); + } + + private handleMessage(socket: WebSocket, raw: WebSocket.RawData): void { + if (this.socket !== socket) return; + let message: CdpMessage; + try { + message = JSON.parse(raw.toString()) as CdpMessage; + } catch { + return; + } + + if (message.id !== undefined) { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) { + pending.reject(commandError(pending.method, message.error.message)); + } else { + pending.resolve(message.result); + } + return; + } + + if (message.method === "Target.detachedFromTarget") { + const detachedSessionId = (message.params as { sessionId?: string }) + ?.sessionId; + if (detachedSessionId) this.sessions.delete(detachedSessionId); + return; + } + + if (!message.sessionId || !message.method) return; + const listeners = this.sessions.get(message.sessionId)?.get(message.method); + for (const listener of listeners ?? []) listener(message.params); + } + + private disconnect(socket: WebSocket | null, error: Error): void { + if (!socket || this.socket !== socket) return; + this.socket = null; + this.websocketUrl = null; + this.sessions.clear(); + for (const pending of this.pending.values()) pending.reject(error); + this.pending.clear(); + } +} + +class AttachedNetworkCdpSession implements NetworkCdpSession { + constructor( + private readonly sidecar: NetworkCdpSidecar, + private readonly sessionId: string, + ) {} + + get connected(): boolean { + return this.sidecar.hasSession(this.sessionId); + } + + detach(): Promise { + return this.sidecar.detach(this.sessionId); + } + + off(event: string, listener: CdpEventListener): void { + this.sidecar.off(this.sessionId, event, listener); + } + + on(event: string, listener: CdpEventListener): void { + this.sidecar.on(this.sessionId, event, listener); + } + + send( + method: string, + params?: Record, + ): Promise { + return this.sidecar.sendToSession(this.sessionId, method, params); + } +} + +function connectionError(cause: unknown): DriverError { + return new DriverError("Failed to connect the network capture CDP sidecar.", { + cause, + code: "network_sidecar_connect_failed", + }); +} + +function commandError(method: string, cause: unknown): DriverError { + return new DriverError(`Network capture CDP command ${method} failed.`, { + cause, + code: "network_sidecar_command_failed", + }); +} diff --git a/packages/cli/src/lib/driver/session-manager.ts b/packages/cli/src/lib/driver/session-manager.ts index 4e9381b70e..8c8aae72d3 100644 --- a/packages/cli/src/lib/driver/session-manager.ts +++ b/packages/cli/src/lib/driver/session-manager.ts @@ -161,6 +161,18 @@ export class DriverSessionManager { return this.stagehand; } + async networkWebSocketDebuggerUrl(): Promise { + const stagehand = await this.stagehandInstance(); + const websocketUrl = stagehand.rpcClient?.browserWebSocketDebuggerUrl; + if (!websocketUrl) { + throw new DriverError( + "Stagehand did not expose the browser CDP endpoint required for network capture.", + { code: "network_sidecar_endpoint_unavailable" }, + ); + } + return websocketUrl; + } + async status(): Promise { if (!this.stagehand || !this.context) { return { @@ -212,7 +224,7 @@ export class DriverSessionManager { this.browserbaseIdentityValue = {}; this.initFailure = null; this.consecutiveInitFailures = 0; - await this.network.disable().catch(() => undefined); + await this.network.close().catch(() => undefined); if (stagehand) { await stagehand.close().catch(() => undefined); } diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index 6861b6d2b3..dc55048ee9 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -457,24 +457,24 @@ describe("driver commands", () => { } }); - it("reports the isolated V4 network-capture gap", async () => { + it("enables sidecar network capture", async () => { const page = {}; const network = { - enable: vi.fn(async () => { - throw new Error("Network capture is not available"); - }), + enable: vi.fn(async () => ({ enabled: true, path: "/tmp/network" })), }; const manager = { activePage: vi.fn(async () => page), network, + networkWebSocketDebuggerUrl: vi.fn(async () => "ws://sidecar.test"), } as unknown as Parameters< NonNullable<(typeof networkHandlers)["network.on"]> >[0]; - await expect(networkHandlers["network.on"]!(manager, {})).rejects.toThrow( - "Network capture is not available", - ); - expect(network.enable).toHaveBeenCalledWith(page); + await expect(networkHandlers["network.on"]!(manager, {})).resolves.toEqual({ + enabled: true, + path: "/tmp/network", + }); + expect(network.enable).toHaveBeenCalledWith(page, "ws://sidecar.test"); }); it("installs the CLI-owned cursor overlay", async () => { diff --git a/packages/cli/tests/network-capture.test.ts b/packages/cli/tests/network-capture.test.ts new file mode 100644 index 0000000000..6fe871351d --- /dev/null +++ b/packages/cli/tests/network-capture.test.ts @@ -0,0 +1,239 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { + NetworkCdpSession, + NetworkCdpSidecar, +} from "../src/lib/driver/network-cdp-sidecar.js"; +import { NetworkCapture } from "../src/lib/driver/network-capture.js"; + +const cleanupPaths: string[] = []; + +afterEach(async () => { + while (cleanupPaths.length > 0) { + const cleanupPath = cleanupPaths.pop(); + if (cleanupPath) { + await fs.rm(cleanupPath, { force: true, recursive: true }); + } + } +}); + +describe("NetworkCapture", () => { + it("preserves the V3 files and keeps its sidecar alive across on/off/on", async () => { + const daemonDir = await fs.mkdtemp( + join(tmpdir(), "browse-network-sidecar-"), + ); + cleanupPaths.push(daemonDir); + const previousDaemonDir = process.env.BROWSE_DAEMON_DIR; + process.env.BROWSE_DAEMON_DIR = daemonDir; + const session = new FakeCdpSession(); + const sidecar = { + attach: vi.fn(async () => session), + close: vi.fn(), + }; + const capture = new NetworkCapture( + "sidecar", + sidecar as unknown as NetworkCdpSidecar, + ); + const originalWriteFile = fs.writeFile.bind(fs); + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (...args) => { + if (String(args[0]).endsWith("request.json")) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return originalWriteFile(...args); + }); + + try { + await expect( + capture.enable({ pageId: "page-1" }, "ws://browser.test"), + ).resolves.toMatchObject({ enabled: true }); + expect(sidecar.attach).toHaveBeenCalledWith( + "ws://browser.test", + "page-1", + ); + + session.emit("Network.requestWillBeSent", { + request: { + headers: { accept: "text/plain" }, + method: "POST", + postData: "hello=world", + url: "https://example.com/fast", + }, + requestId: "request-1", + type: "Fetch", + }); + session.emit("Network.responseReceived", { + requestId: "request-1", + response: { + headers: { "content-type": "text/plain" }, + mimeType: "text/plain", + status: 200, + statusText: "OK", + }, + }); + session.emit("Network.loadingFinished", { requestId: "request-1" }); + + const requestDir = join( + daemonDir, + "sidecar-network", + "000-POST-example.com-fast", + ); + const responsePath = join(requestDir, "response.json"); + await waitForFile(responsePath); + await expect( + readJson(join(requestDir, "request.json")), + ).resolves.toMatchObject({ + body: "hello=world", + method: "POST", + resourceType: "Fetch", + url: "https://example.com/fast", + }); + await expect(readJson(responsePath)).resolves.toMatchObject({ + body: "ok", + mimeType: "text/plain", + status: 200, + statusText: "OK", + }); + + await expect(capture.disable()).resolves.toMatchObject({ + enabled: false, + }); + expect(session.detach).toHaveBeenCalledOnce(); + expect(sidecar.close).not.toHaveBeenCalled(); + + session.connected = true; + await capture.enable({ pageId: "page-2" }, "ws://browser.test"); + expect(sidecar.attach).toHaveBeenLastCalledWith( + "ws://browser.test", + "page-2", + ); + expect(sidecar.attach).toHaveBeenCalledTimes(2); + + session.emit("Network.requestWillBeSent", { + request: { + method: "POST", + url: "https://example.com/fast", + }, + requestId: "request-2", + type: "Fetch", + }); + session.emit("Network.loadingFinished", { requestId: "request-2" }); + await waitForFile( + join( + daemonDir, + "sidecar-network", + "001-POST-example.com-fast", + "response.json", + ), + ); + await expect( + readJson(join(requestDir, "request.json")), + ).resolves.toMatchObject({ + id: "request-1", + }); + + await capture.close(); + expect(sidecar.close).toHaveBeenCalledOnce(); + } finally { + writeFileSpy.mockRestore(); + restoreEnv("BROWSE_DAEMON_DIR", previousDaemonDir); + } + }); + + it("serializes overlapping enable operations", async () => { + const daemonDir = await fs.mkdtemp( + join(tmpdir(), "browse-network-overlap-"), + ); + cleanupPaths.push(daemonDir); + const previousDaemonDir = process.env.BROWSE_DAEMON_DIR; + process.env.BROWSE_DAEMON_DIR = daemonDir; + const session = new FakeCdpSession(); + const sidecar = { + attach: vi.fn(async () => session), + close: vi.fn(), + }; + const capture = new NetworkCapture( + "overlap", + sidecar as unknown as NetworkCdpSidecar, + ); + + try { + const [first, second] = await Promise.all([ + capture.enable({ pageId: "page-1" }, "ws://browser.test"), + capture.enable({ pageId: "page-1" }, "ws://browser.test"), + ]); + + expect(first).toMatchObject({ enabled: true }); + expect(second).toMatchObject({ alreadyEnabled: true, enabled: true }); + expect(sidecar.attach).toHaveBeenCalledOnce(); + } finally { + await capture.close(); + restoreEnv("BROWSE_DAEMON_DIR", previousDaemonDir); + } + }); +}); + +class FakeCdpSession implements NetworkCdpSession { + connected = true; + readonly detach = vi.fn(async () => { + this.connected = false; + }); + private readonly listeners = new Map< + string, + Set<(params: unknown) => void> + >(); + + async send(method: string): Promise { + if (method === "Network.getResponseBody") { + return { body: "ok" } as T; + } + return {} as T; + } + + on(event: string, listener: (params: unknown) => void): void { + const listeners = this.listeners.get(event) ?? new Set(); + listeners.add(listener); + this.listeners.set(event, listeners); + } + + off(event: string, listener: (params: unknown) => void): void { + this.listeners.get(event)?.delete(listener); + } + + emit(event: string, params: unknown): void { + for (const listener of this.listeners.get(event) ?? []) listener(params); + } +} + +async function readJson(filePath: string): Promise> { + return JSON.parse(await fs.readFile(filePath, "utf8")) as Record< + string, + unknown + >; +} + +async function waitForFile(filePath: string): Promise { + const start = Date.now(); + while (Date.now() - start < 1_000) { + try { + await fs.access(filePath); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw new Error(`Timed out waiting for ${filePath}`); +} + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + return; + } + process.env[key] = value; +} diff --git a/packages/cli/tests/network-cdp-sidecar.test.ts b/packages/cli/tests/network-cdp-sidecar.test.ts new file mode 100644 index 0000000000..5692183d28 --- /dev/null +++ b/packages/cli/tests/network-cdp-sidecar.test.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from "node:events"; + +import type WebSocket from "ws"; +import { describe, expect, it, vi } from "vitest"; + +import { NetworkCdpSidecar } from "../src/lib/driver/network-cdp-sidecar.js"; + +describe("NetworkCdpSidecar", () => { + it("reuses one browser WebSocket across detached page sessions", async () => { + const socket = new FakeWebSocket(); + const factory = vi.fn(() => { + queueMicrotask(() => socket.open()); + return socket as unknown as WebSocket; + }); + const sidecar = new NetworkCdpSidecar(factory); + + const first = await sidecar.attach("ws://browser.test", "page-1"); + const listener = vi.fn(); + first.on("Network.requestWillBeSent", listener); + socket.emitCdp({ + method: "Network.requestWillBeSent", + params: { requestId: "request-1" }, + sessionId: "sidecar-session-1", + }); + + expect(listener).toHaveBeenCalledWith({ requestId: "request-1" }); + await expect(first.send("Network.enable")).resolves.toEqual({}); + await first.detach(); + expect(first.connected).toBe(false); + + const second = await sidecar.attach("ws://browser.test", "page-2"); + expect(second.connected).toBe(true); + expect(factory).toHaveBeenCalledOnce(); + expect(socket.sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: "Target.attachToTarget", + params: { flatten: true, targetId: "page-1" }, + }), + expect.objectContaining({ + method: "Target.detachFromTarget", + params: { sessionId: "sidecar-session-1" }, + }), + expect.objectContaining({ + method: "Target.attachToTarget", + params: { flatten: true, targetId: "page-2" }, + }), + ]), + ); + + sidecar.close(); + expect(socket.closed).toBe(true); + }); +}); + +class FakeWebSocket extends EventEmitter { + closed = false; + readyState = 0; + readonly sent: Array<{ + id: number; + method: string; + params: Record; + sessionId?: string; + }> = []; + private sessionCounter = 0; + + open(): void { + this.readyState = 1; + this.emit("open"); + } + + send(raw: string): void { + const message = JSON.parse(raw) as (typeof this.sent)[number]; + this.sent.push(message); + const result = + message.method === "Target.attachToTarget" + ? { sessionId: `sidecar-session-${++this.sessionCounter}` } + : {}; + queueMicrotask(() => this.emitCdp({ id: message.id, result })); + } + + close(): void { + this.closed = true; + this.readyState = 3; + this.emit("close"); + } + + emitCdp(message: Record): void { + this.emit("message", Buffer.from(JSON.stringify(message))); + } +} From eb42f45eaac6a43a35e65c608cd1ccc698810665 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 11 Sep 2026 20:17:44 +0000 Subject: [PATCH 3/8] test(cli): include network wait timeout duration --- packages/cli/tests/network-capture.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/tests/network-capture.test.ts b/packages/cli/tests/network-capture.test.ts index 6fe871351d..6a25da8348 100644 --- a/packages/cli/tests/network-capture.test.ts +++ b/packages/cli/tests/network-capture.test.ts @@ -218,8 +218,9 @@ async function readJson(filePath: string): Promise> { } async function waitForFile(filePath: string): Promise { + const timeoutMs = 1_000; const start = Date.now(); - while (Date.now() - start < 1_000) { + while (Date.now() - start < timeoutMs) { try { await fs.access(filePath); return; @@ -227,7 +228,7 @@ async function waitForFile(filePath: string): Promise { await new Promise((resolve) => setTimeout(resolve, 10)); } } - throw new Error(`Timed out waiting for ${filePath}`); + throw new Error(`Timed out waiting for ${filePath} after ${timeoutMs}ms`); } function restoreEnv(key: string, value: string | undefined): void { From 210433cb8e570262a8c3ab0160237408b1ddedf3 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 11 Sep 2026 18:15:08 +0000 Subject: [PATCH 4/8] refactor(cli): remove coordinate XPath return flag --- .changeset/brave-browsers-migrate.md | 5 ++++ packages/cli/README.md | 2 +- packages/cli/src/commands/mouse/click.ts | 7 +---- packages/cli/src/commands/mouse/drag.ts | 6 ----- packages/cli/src/commands/mouse/hover.ts | 8 +----- packages/cli/src/commands/mouse/scroll.ts | 8 +----- packages/cli/src/lib/driver/commands/mouse.ts | 26 ++++++------------- packages/cli/tests/driver-commands.test.ts | 18 ++++++++++--- 8 files changed, 31 insertions(+), 49 deletions(-) create mode 100644 .changeset/brave-browsers-migrate.md diff --git a/.changeset/brave-browsers-migrate.md b/.changeset/brave-browsers-migrate.md new file mode 100644 index 0000000000..608dbe48d8 --- /dev/null +++ b/.changeset/brave-browsers-migrate.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +migrate the Browse CLI runtime to Stagehand V4 and remove the `--return-xpath` option from coordinate actions diff --git a/packages/cli/README.md b/packages/cli/README.md index 416dc1b3a9..58e8b5b142 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -111,7 +111,7 @@ browse highlight @0-12 # Highlight an element (--duratio ### Mouse (raw coordinates) -Use these when you need pixel coordinates instead of a ref. Add `--return-xpath` to get the XPath under the cursor. +Use these when you need pixel coordinates instead of a ref. ```bash browse mouse click 240 320 # Click coordinates (--button, --click-count) diff --git a/packages/cli/src/commands/mouse/click.ts b/packages/cli/src/commands/mouse/click.ts index ad0f21d35b..32a981c6bf 100644 --- a/packages/cli/src/commands/mouse/click.ts +++ b/packages/cli/src/commands/mouse/click.ts @@ -15,7 +15,7 @@ export default class MouseClick extends BrowseCommand { static override examples = [ "browse mouse click 240 320", "browse mouse click 240 320 --button right", - "browse mouse click 240 320 --click-count 2 --return-xpath", + "browse mouse click 240 320 --click-count 2", ]; static override args = { @@ -31,10 +31,6 @@ export default class MouseClick extends BrowseCommand { description: "Number of clicks to send.", helpValue: "", }), - "return-xpath": Flags.boolean({ - description: - "Include the XPath under the coordinate when the driver can return it.", - }), }; async run(): Promise { @@ -44,7 +40,6 @@ export default class MouseClick extends BrowseCommand { { button: flags.button, clickCount: flags["click-count"], - returnXPath: flags["return-xpath"], x: parseNumber(args.x, "x"), y: parseNumber(args.y, "y"), }, diff --git a/packages/cli/src/commands/mouse/drag.ts b/packages/cli/src/commands/mouse/drag.ts index d2a57cff0d..a056abf05f 100644 --- a/packages/cli/src/commands/mouse/drag.ts +++ b/packages/cli/src/commands/mouse/drag.ts @@ -15,7 +15,6 @@ export default class MouseDrag extends BrowseCommand { static override examples = [ "browse mouse drag 100 100 400 400", "browse mouse drag 100 100 400 400 --steps 20 --delay 10", - "browse mouse drag 100 100 400 400 --return-xpath", ]; static override args = { @@ -45,10 +44,6 @@ export default class MouseDrag extends BrowseCommand { description: "Delay between drag steps in milliseconds.", helpValue: "", }), - "return-xpath": Flags.boolean({ - description: - "Include the XPath under the start/end coordinates when the driver can return it.", - }), steps: Flags.integer({ default: 10, description: "Number of intermediate drag steps.", @@ -65,7 +60,6 @@ export default class MouseDrag extends BrowseCommand { delay: flags.delay, fromX: parseNumber(args.fromX, "fromX"), fromY: parseNumber(args.fromY, "fromY"), - returnXPath: flags["return-xpath"], steps: flags.steps, toX: parseNumber(args.toX, "toX"), toY: parseNumber(args.toY, "toY"), diff --git a/packages/cli/src/commands/mouse/hover.ts b/packages/cli/src/commands/mouse/hover.ts index 16c7d537bb..ed7fd2d03a 100644 --- a/packages/cli/src/commands/mouse/hover.ts +++ b/packages/cli/src/commands/mouse/hover.ts @@ -1,4 +1,4 @@ -import { Args, Flags } from "@oclif/core"; +import { Args } from "@oclif/core"; import { BrowseCommand } from "../../base.js"; import { @@ -13,7 +13,6 @@ export default class MouseHover extends BrowseCommand { static override examples = [ "browse mouse hover 240 320", - "browse mouse hover 240 320 --return-xpath", "browse mouse hover 240 320 --session research", ]; @@ -24,10 +23,6 @@ export default class MouseHover extends BrowseCommand { static override flags = { ...driverCommandFlags, - "return-xpath": Flags.boolean({ - description: - "Include the XPath under the coordinate when the driver can return it.", - }), }; async run(): Promise { @@ -35,7 +30,6 @@ export default class MouseHover extends BrowseCommand { await runDriverCommandFromFlags( "mouse.hover", { - returnXPath: flags["return-xpath"], x: parseNumber(args.x, "x"), y: parseNumber(args.y, "y"), }, diff --git a/packages/cli/src/commands/mouse/scroll.ts b/packages/cli/src/commands/mouse/scroll.ts index 882d963826..9a135c7833 100644 --- a/packages/cli/src/commands/mouse/scroll.ts +++ b/packages/cli/src/commands/mouse/scroll.ts @@ -1,4 +1,4 @@ -import { Args, Flags } from "@oclif/core"; +import { Args } from "@oclif/core"; import { BrowseCommand } from "../../base.js"; import { @@ -14,7 +14,6 @@ export default class MouseScroll extends BrowseCommand { static override examples = [ "browse mouse scroll 400 500 0 600", "browse mouse scroll 400 500 0 -600", - "browse mouse scroll 400 500 0 600 --return-xpath", ]; static override args = { @@ -32,10 +31,6 @@ export default class MouseScroll extends BrowseCommand { static override flags = { ...driverCommandFlags, - "return-xpath": Flags.boolean({ - description: - "Include the XPath under the coordinate when the driver can return it.", - }), }; async run(): Promise { @@ -45,7 +40,6 @@ export default class MouseScroll extends BrowseCommand { { deltaX: parseNumber(args.deltaX, "deltaX"), deltaY: parseNumber(args.deltaY, "deltaY"), - returnXPath: flags["return-xpath"], x: parseNumber(args.x, "x"), y: parseNumber(args.y, "y"), }, diff --git a/packages/cli/src/lib/driver/commands/mouse.ts b/packages/cli/src/lib/driver/commands/mouse.ts index cb0c309562..ae958a6b67 100644 --- a/packages/cli/src/lib/driver/commands/mouse.ts +++ b/packages/cli/src/lib/driver/commands/mouse.ts @@ -8,16 +8,15 @@ const ButtonSchema = z.enum(["left", "right", "middle"]).optional(); export const mouseHandlers: DriverCommandHandlers = { async "mouse.click"(manager, params) { - const { button, clickCount, returnXPath, x, y } = z + const { button, clickCount, x, y } = z .object({ button: ButtonSchema, clickCount: z.number().int().positive().optional(), - returnXPath: z.boolean().optional(), x: z.number(), y: z.number(), }) + .strict() .parse(params); - assertXPathUnavailable(returnXPath); const page = await manager.activePage(); await positionCursorOverlay(manager, page, x, y); await page.click(x, y, { @@ -28,14 +27,13 @@ export const mouseHandlers: DriverCommandHandlers = { }, async "mouse.hover"(manager, params) { - const { returnXPath, x, y } = z + const { x, y } = z .object({ - returnXPath: z.boolean().optional(), x: z.number(), y: z.number(), }) + .strict() .parse(params); - assertXPathUnavailable(returnXPath); const page = await manager.activePage(); await positionCursorOverlay(manager, page, x, y); await page.hover(x, y); @@ -43,16 +41,15 @@ export const mouseHandlers: DriverCommandHandlers = { }, async "mouse.scroll"(manager, params) { - const { deltaX, deltaY, returnXPath, x, y } = z + const { deltaX, deltaY, x, y } = z .object({ deltaX: z.number(), deltaY: z.number(), - returnXPath: z.boolean().optional(), x: z.number(), y: z.number(), }) + .strict() .parse(params); - assertXPathUnavailable(returnXPath); const page = await manager.activePage(); await positionCursorOverlay(manager, page, x, y); await page.scroll(x, y, deltaX, deltaY); @@ -60,19 +57,18 @@ export const mouseHandlers: DriverCommandHandlers = { }, async "mouse.drag"(manager, params) { - const { button, delay, fromX, fromY, returnXPath, steps, toX, toY } = z + const { button, delay, fromX, fromY, steps, toX, toY } = z .object({ button: ButtonSchema, delay: z.number().int().nonnegative().optional(), fromX: z.number(), fromY: z.number(), - returnXPath: z.boolean().optional(), steps: z.number().int().positive().optional(), toX: z.number(), toY: z.number(), }) + .strict() .parse(params); - assertXPathUnavailable(returnXPath); const page = await manager.activePage(); await positionCursorOverlay(manager, page, fromX, fromY); await page.dragAndDrop(fromX, fromY, toX, toY, { @@ -97,9 +93,3 @@ async function positionCursorOverlay( if (!manager.isCursorOverlayEnabled(page)) return; await page.evaluate(updateCursorOverlayPosition, { x, y }); } - -function assertXPathUnavailable(returnXPath: boolean | undefined): void { - if (returnXPath) { - throw new Error("Coordinate XPath lookup is not exposed by Stagehand V4"); - } -} diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index dc55048ee9..57c218bbe3 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -440,19 +440,22 @@ describe("driver commands", () => { expect(page.evaluate).toHaveBeenCalledTimes(2); }); - it("fails explicitly for the V4 coordinate XPath capability", async () => { + it("rejects removed coordinate XPath fields at the driver boundary", async () => { const manager = {} as Parameters< NonNullable<(typeof mouseHandlers)["mouse.click"]> >[0]; for (const [command, params] of [ ["mouse.click", { returnXPath: true, x: 1, y: 2 }], - ["mouse.hover", { returnXPath: true, x: 1, y: 2 }], + ["mouse.hover", { returnXPath: false, x: 1, y: 2 }], ["mouse.scroll", { deltaX: 0, deltaY: 1, returnXPath: true, x: 1, y: 2 }], - ["mouse.drag", { fromX: 1, fromY: 2, returnXPath: true, toX: 3, toY: 4 }], + [ + "mouse.drag", + { fromX: 1, fromY: 2, returnXPath: false, toX: 3, toY: 4 }, + ], ] as const) { await expect(mouseHandlers[command]!(manager, params)).rejects.toThrow( - "Coordinate XPath lookup is not exposed by Stagehand V4", + /returnXPath/, ); } }); @@ -792,6 +795,13 @@ describe("driver commands", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Prefer targetId"); }); + it("does not expose the removed coordinate XPath flag", async () => { + for (const command of ["click", "hover", "scroll", "drag"]) { + const result = await runCli(["mouse", command, "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain("--return-xpath"); + } + }); }); type FakeTabPage = { From 5935a1acf7ac4d5ccfadfc6ede61a853708ac2bb Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 11 Sep 2026 18:14:47 +0000 Subject: [PATCH 5/8] fix(cli): capture network traffic through a CDP sidecar --- .../cli/src/lib/driver/commands/network.ts | 3 +- .../cli/src/lib/driver/network-capture.ts | 151 ++++++--- .../cli/src/lib/driver/network-cdp-sidecar.ts | 307 ++++++++++++++++++ .../cli/src/lib/driver/session-manager.ts | 14 +- packages/cli/tests/driver-commands.test.ts | 16 +- packages/cli/tests/network-capture.test.ts | 239 ++++++++++++++ .../cli/tests/network-cdp-sidecar.test.ts | 91 ++++++ 7 files changed, 764 insertions(+), 57 deletions(-) create mode 100644 packages/cli/src/lib/driver/network-cdp-sidecar.ts create mode 100644 packages/cli/tests/network-capture.test.ts create mode 100644 packages/cli/tests/network-cdp-sidecar.test.ts diff --git a/packages/cli/src/lib/driver/commands/network.ts b/packages/cli/src/lib/driver/commands/network.ts index 5887f132c9..9e52c0f727 100644 --- a/packages/cli/src/lib/driver/commands/network.ts +++ b/packages/cli/src/lib/driver/commands/network.ts @@ -3,7 +3,8 @@ import type { DriverCommandHandlers } from "./types.js"; export const networkHandlers: DriverCommandHandlers = { async "network.on"(manager) { const page = await manager.activePage(); - return manager.network.enable(page); + const websocketUrl = await manager.networkWebSocketDebuggerUrl(); + return manager.network.enable(page, websocketUrl); }, async "network.off"(manager) { diff --git a/packages/cli/src/lib/driver/network-capture.ts b/packages/cli/src/lib/driver/network-capture.ts index 921f58afb2..7da01b35dd 100644 --- a/packages/cli/src/lib/driver/network-capture.ts +++ b/packages/cli/src/lib/driver/network-capture.ts @@ -7,7 +7,10 @@ import { getNetworkDir, writePrivateFile, } from "./daemon/paths.js"; -import { DriverError } from "./errors.js"; +import { + NetworkCdpSidecar, + type NetworkCdpSession, +} from "./network-cdp-sidecar.js"; interface PendingRequest { body: string | null; @@ -26,71 +29,102 @@ interface ResponseMetadata { statusText: string; } -type CdpSession = { - off?: (event: string, listener: (...args: unknown[]) => void) => void; - on: (event: string, listener: (...args: unknown[]) => void) => void; - send: ( - method: string, - params?: Record, - ) => Promise; +type StagehandV4Page = { + pageId: string; }; +/** + * The V3 Browse network writer, adapted only at the CDP-session boundary. + * Keeping request correlation and the on-disk request/response schema here + * unchanged gives the V4 CLI observable parity without adding a public + * Stagehand network-event API. + */ export class NetworkCapture { - private cdpSession: CdpSession | null = null; + private cdpSession: NetworkCdpSession | null = null; private counter = 0; private enabled = false; + private lifecycle: Promise = Promise.resolve(); private readonly pendingRequests = new Map(); private readonly requestDirs = new Map>(); private readonly requestStartTimes = new Map(); private readonly responseMetadata = new Map(); - private readonly listeners: Array<[string, (...args: unknown[]) => void]> = - []; + private readonly listeners: Array<[string, (params: unknown) => void]> = []; private networkDir: string | null = null; - constructor(private readonly session: string) {} + constructor( + private readonly session: string, + private readonly sidecar = new NetworkCdpSidecar(), + ) {} async enable( - page: unknown, + page: StagehandV4Page, + browserWebSocketDebuggerUrl: string, ): Promise<{ alreadyEnabled?: boolean; enabled: true; path: string }> { - if (this.enabled && this.networkDir) { + return this.runLifecycle(() => + this.enableNow(page, browserWebSocketDebuggerUrl), + ); + } + + async disable(): Promise<{ + alreadyDisabled?: boolean; + enabled: false; + path: string | null; + }> { + return this.runLifecycle(() => this.disableNow()); + } + + private async enableNow( + page: StagehandV4Page, + browserWebSocketDebuggerUrl: string, + ): Promise<{ alreadyEnabled?: boolean; enabled: true; path: string }> { + if (this.enabled && this.networkDir && this.cdpSession?.connected) { return { alreadyEnabled: true, enabled: true, path: this.networkDir }; } - - const cdpSession = await this.networkCdpSession(page); + if (this.enabled) await this.disableNow(); await ensureRuntimeDir(); this.networkDir = getNetworkDir(this.session); await ensurePrivateDir(this.networkDir); - this.counter = 0; + this.counter = await nextRequestCounter(this.networkDir); this.pendingRequests.clear(); this.requestDirs.clear(); this.requestStartTimes.clear(); this.responseMetadata.clear(); + const cdpSession = await this.sidecar.attach( + browserWebSocketDebuggerUrl, + page.pageId, + ); this.cdpSession = cdpSession; - await cdpSession.send("Network.enable", { - maxResourceBufferSize: 5_000_000, - maxTotalBufferSize: 10_000_000, - }); + try { + await cdpSession.send("Network.enable", { + maxResourceBufferSize: 5_000_000, + maxTotalBufferSize: 10_000_000, + }); - this.addListener("Network.requestWillBeSent", (params) => { - void this.handleRequestWillBeSent(params); - }); - this.addListener("Network.responseReceived", (params) => { - this.handleResponseReceived(params); - }); - this.addListener("Network.loadingFinished", (params) => { - void this.handleLoadingFinished(params); - }); - this.addListener("Network.loadingFailed", (params) => { - void this.handleLoadingFailed(params); - }); + this.addListener("Network.requestWillBeSent", (params) => { + void this.handleRequestWillBeSent(params); + }); + this.addListener("Network.responseReceived", (params) => { + this.handleResponseReceived(params); + }); + this.addListener("Network.loadingFinished", (params) => { + void this.handleLoadingFinished(params); + }); + this.addListener("Network.loadingFailed", (params) => { + void this.handleLoadingFailed(params); + }); + } catch (error) { + this.cdpSession = null; + await cdpSession.detach().catch(() => undefined); + throw error; + } this.enabled = true; return { enabled: true, path: this.networkDir }; } - async disable(): Promise<{ + private async disableNow(): Promise<{ alreadyDisabled?: boolean; enabled: false; path: string | null; @@ -99,20 +133,36 @@ export class NetworkCapture { return { alreadyDisabled: true, enabled: false, path: this.networkDir }; } + const cdpSession = this.cdpSession; + this.enabled = false; for (const [event, listener] of this.listeners) { - this.cdpSession?.off?.(event, listener); + cdpSession?.off(event, listener); } this.listeners.length = 0; - await this.cdpSession?.send("Network.disable").catch(() => undefined); + await cdpSession?.send("Network.disable").catch(() => undefined); + await cdpSession?.detach().catch(() => undefined); this.cdpSession = null; - this.enabled = false; return { enabled: false, path: this.networkDir }; } + private runLifecycle(operation: () => Promise): Promise { + const result = this.lifecycle.then(operation, operation); + this.lifecycle = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async close(): Promise { + await this.disable().catch(() => undefined); + this.sidecar.close(); + } + path(): { enabled: boolean; path: string } { return { - enabled: this.enabled, + enabled: this.enabled && (this.cdpSession?.connected ?? false), path: this.networkDir ?? getNetworkDir(this.session), }; } @@ -146,20 +196,12 @@ export class NetworkCapture { private addListener( event: string, - listener: (...args: unknown[]) => void, + listener: (params: unknown) => void, ): void { this.cdpSession?.on(event, listener); this.listeners.push([event, listener]); } - private async networkCdpSession(page: unknown): Promise { - void page; - throw new DriverError( - "Network capture is not available in this Stagehand V4 runtime. Apply the CLI CDP sidecar fast-follow to restore `browse network on`.", - { code: "network_capture_unavailable" }, - ); - } - private handleRequestWillBeSent(params: unknown): void { if (!this.enabled || !this.networkDir) return; const event = params as { @@ -330,6 +372,21 @@ function getRequestDirName( } } +async function nextRequestCounter(networkDir: string): Promise { + const entries = await fs.readdir(networkDir, { withFileTypes: true }); + let next = 0; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const match = /^(\d+)-/u.exec(entry.name); + if (!match) continue; + const rawCounter = match[1]; + if (!rawCounter) continue; + const counter = Number.parseInt(rawCounter, 10); + if (Number.isSafeInteger(counter)) next = Math.max(next, counter + 1); + } + return next; +} + function sanitizeForFilename(value: string, maxLen: number): string { return value .replace(/[^a-zA-Z0-9.-]/g, "-") diff --git a/packages/cli/src/lib/driver/network-cdp-sidecar.ts b/packages/cli/src/lib/driver/network-cdp-sidecar.ts new file mode 100644 index 0000000000..76b4ce4729 --- /dev/null +++ b/packages/cli/src/lib/driver/network-cdp-sidecar.ts @@ -0,0 +1,307 @@ +import WebSocket from "ws"; + +import { DriverError } from "./errors.js"; + +type CdpEventListener = (params: unknown) => void; + +interface CdpMessage { + error?: { code: number; message: string }; + id?: number; + method?: string; + params?: unknown; + result?: unknown; + sessionId?: string; +} + +interface PendingCommand { + method: string; + reject: (error: Error) => void; + resolve: (result: unknown) => void; +} + +export interface NetworkCdpSession { + readonly connected: boolean; + detach(): Promise; + off(event: string, listener: CdpEventListener): void; + on(event: string, listener: CdpEventListener): void; + send( + method: string, + params?: Record, + ): Promise; +} + +export type NetworkCdpWebSocketFactory = (url: string) => WebSocket; + +/** + * A CLI-owned browser-level CDP connection used only for V3-compatible + * network capture. The connection is intentionally kept alive across + * `network off`/`network on`: Browserbase treats closing an auxiliary browser + * WebSocket as a browser-session disconnect. It is closed only with the Browse + * driver session. + */ +export class NetworkCdpSidecar { + private connecting: Promise | null = null; + private nextId = 1; + private readonly pending = new Map(); + private readonly sessions = new Map< + string, + Map> + >(); + private socket: WebSocket | null = null; + private websocketUrl: string | null = null; + + constructor( + private readonly createWebSocket: NetworkCdpWebSocketFactory = (url) => + new WebSocket(url), + ) {} + + async attach( + websocketUrl: string, + targetId: string, + ): Promise { + await this.ensureConnected(websocketUrl); + const { sessionId } = await this.sendCommand<{ sessionId: string }>( + "Target.attachToTarget", + { flatten: true, targetId }, + ); + this.sessions.set(sessionId, new Map()); + return new AttachedNetworkCdpSession(this, sessionId); + } + + close(): void { + const socket = this.socket; + this.disconnect( + socket, + new DriverError("Network capture CDP sidecar closed.", { + code: "network_sidecar_closed", + }), + ); + if ( + socket && + (socket.readyState === WebSocket.OPEN || + socket.readyState === WebSocket.CONNECTING) + ) { + socket.close(); + } + } + + hasSession(sessionId: string): boolean { + return ( + this.socket?.readyState === WebSocket.OPEN && this.sessions.has(sessionId) + ); + } + + on(sessionId: string, event: string, listener: CdpEventListener): void { + const listeners = this.sessions.get(sessionId); + if (!listeners) return; + const eventListeners = listeners.get(event) ?? new Set(); + eventListeners.add(listener); + listeners.set(event, eventListeners); + } + + off(sessionId: string, event: string, listener: CdpEventListener): void { + const listeners = this.sessions.get(sessionId); + const eventListeners = listeners?.get(event); + eventListeners?.delete(listener); + if (eventListeners?.size === 0) listeners?.delete(event); + } + + async sendToSession( + sessionId: string, + method: string, + params: Record = {}, + ): Promise { + if (!this.sessions.has(sessionId)) { + throw new DriverError("Network capture CDP session is detached.", { + code: "network_sidecar_detached", + }); + } + return this.sendCommand(method, params, sessionId); + } + + async detach(sessionId: string): Promise { + if (!this.sessions.has(sessionId)) return; + this.sessions.delete(sessionId); + await this.sendCommand("Target.detachFromTarget", { sessionId }).catch( + () => undefined, + ); + } + + private async ensureConnected(websocketUrl: string): Promise { + if (this.socket?.readyState === WebSocket.OPEN) { + if (this.websocketUrl !== websocketUrl) { + throw new DriverError( + "The browser CDP endpoint changed during the Browse session.", + { code: "network_sidecar_endpoint_changed" }, + ); + } + return; + } + if (this.connecting) { + await this.connecting; + return; + } + + const connecting = this.open(websocketUrl); + this.connecting = connecting; + try { + await connecting; + } finally { + if (this.connecting === connecting) this.connecting = null; + } + } + + private async open(websocketUrl: string): Promise { + let socket: WebSocket; + try { + socket = this.createWebSocket(websocketUrl); + } catch (error) { + throw connectionError(error); + } + this.socket = socket; + this.websocketUrl = websocketUrl; + + await new Promise((resolve, reject) => { + let opened = false; + socket.on("message", (raw: WebSocket.RawData) => { + this.handleMessage(socket, raw); + }); + socket.on("error", (error: Error) => { + const failure = connectionError(error); + this.disconnect(socket, failure); + if (!opened) reject(failure); + }); + socket.on("close", () => { + const error = new DriverError( + "Network capture CDP sidecar disconnected.", + { code: "network_sidecar_disconnected" }, + ); + this.disconnect(socket, error); + if (!opened) reject(connectionError(error)); + }); + socket.once("open", () => { + opened = true; + resolve(); + }); + }); + } + + private sendCommand( + method: string, + params: Record = {}, + sessionId?: string, + ): Promise { + const socket = this.socket; + if (!socket || socket.readyState !== WebSocket.OPEN) { + return Promise.reject( + new DriverError("Network capture CDP sidecar is not connected.", { + code: "network_sidecar_disconnected", + }), + ); + } + + const id = this.nextId++; + const message = sessionId + ? { id, method, params, sessionId } + : { id, method, params }; + return new Promise((resolve, reject) => { + this.pending.set(id, { + method, + reject, + resolve: (result) => resolve(result as T), + }); + try { + socket.send(JSON.stringify(message)); + } catch (error) { + this.pending.delete(id); + reject(commandError(method, error)); + } + }); + } + + private handleMessage(socket: WebSocket, raw: WebSocket.RawData): void { + if (this.socket !== socket) return; + let message: CdpMessage; + try { + message = JSON.parse(raw.toString()) as CdpMessage; + } catch { + return; + } + + if (message.id !== undefined) { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) { + pending.reject(commandError(pending.method, message.error.message)); + } else { + pending.resolve(message.result); + } + return; + } + + if (message.method === "Target.detachedFromTarget") { + const detachedSessionId = (message.params as { sessionId?: string }) + ?.sessionId; + if (detachedSessionId) this.sessions.delete(detachedSessionId); + return; + } + + if (!message.sessionId || !message.method) return; + const listeners = this.sessions.get(message.sessionId)?.get(message.method); + for (const listener of listeners ?? []) listener(message.params); + } + + private disconnect(socket: WebSocket | null, error: Error): void { + if (!socket || this.socket !== socket) return; + this.socket = null; + this.websocketUrl = null; + this.sessions.clear(); + for (const pending of this.pending.values()) pending.reject(error); + this.pending.clear(); + } +} + +class AttachedNetworkCdpSession implements NetworkCdpSession { + constructor( + private readonly sidecar: NetworkCdpSidecar, + private readonly sessionId: string, + ) {} + + get connected(): boolean { + return this.sidecar.hasSession(this.sessionId); + } + + detach(): Promise { + return this.sidecar.detach(this.sessionId); + } + + off(event: string, listener: CdpEventListener): void { + this.sidecar.off(this.sessionId, event, listener); + } + + on(event: string, listener: CdpEventListener): void { + this.sidecar.on(this.sessionId, event, listener); + } + + send( + method: string, + params?: Record, + ): Promise { + return this.sidecar.sendToSession(this.sessionId, method, params); + } +} + +function connectionError(cause: unknown): DriverError { + return new DriverError("Failed to connect the network capture CDP sidecar.", { + cause, + code: "network_sidecar_connect_failed", + }); +} + +function commandError(method: string, cause: unknown): DriverError { + return new DriverError(`Network capture CDP command ${method} failed.`, { + cause, + code: "network_sidecar_command_failed", + }); +} diff --git a/packages/cli/src/lib/driver/session-manager.ts b/packages/cli/src/lib/driver/session-manager.ts index 4e9381b70e..8c8aae72d3 100644 --- a/packages/cli/src/lib/driver/session-manager.ts +++ b/packages/cli/src/lib/driver/session-manager.ts @@ -161,6 +161,18 @@ export class DriverSessionManager { return this.stagehand; } + async networkWebSocketDebuggerUrl(): Promise { + const stagehand = await this.stagehandInstance(); + const websocketUrl = stagehand.rpcClient?.browserWebSocketDebuggerUrl; + if (!websocketUrl) { + throw new DriverError( + "Stagehand did not expose the browser CDP endpoint required for network capture.", + { code: "network_sidecar_endpoint_unavailable" }, + ); + } + return websocketUrl; + } + async status(): Promise { if (!this.stagehand || !this.context) { return { @@ -212,7 +224,7 @@ export class DriverSessionManager { this.browserbaseIdentityValue = {}; this.initFailure = null; this.consecutiveInitFailures = 0; - await this.network.disable().catch(() => undefined); + await this.network.close().catch(() => undefined); if (stagehand) { await stagehand.close().catch(() => undefined); } diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index 1b11e711e1..31fa2051a2 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -520,24 +520,24 @@ describe("driver commands", () => { } }); - it("reports the isolated V4 network-capture gap", async () => { + it("enables sidecar network capture", async () => { const page = {}; const network = { - enable: vi.fn(async () => { - throw new Error("Network capture is not available"); - }), + enable: vi.fn(async () => ({ enabled: true, path: "/tmp/network" })), }; const manager = { activePage: vi.fn(async () => page), network, + networkWebSocketDebuggerUrl: vi.fn(async () => "ws://sidecar.test"), } as unknown as Parameters< NonNullable<(typeof networkHandlers)["network.on"]> >[0]; - await expect(networkHandlers["network.on"]!(manager, {})).rejects.toThrow( - "Network capture is not available", - ); - expect(network.enable).toHaveBeenCalledWith(page); + await expect(networkHandlers["network.on"]!(manager, {})).resolves.toEqual({ + enabled: true, + path: "/tmp/network", + }); + expect(network.enable).toHaveBeenCalledWith(page, "ws://sidecar.test"); }); it("installs the CLI-owned cursor overlay", async () => { diff --git a/packages/cli/tests/network-capture.test.ts b/packages/cli/tests/network-capture.test.ts new file mode 100644 index 0000000000..6fe871351d --- /dev/null +++ b/packages/cli/tests/network-capture.test.ts @@ -0,0 +1,239 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { + NetworkCdpSession, + NetworkCdpSidecar, +} from "../src/lib/driver/network-cdp-sidecar.js"; +import { NetworkCapture } from "../src/lib/driver/network-capture.js"; + +const cleanupPaths: string[] = []; + +afterEach(async () => { + while (cleanupPaths.length > 0) { + const cleanupPath = cleanupPaths.pop(); + if (cleanupPath) { + await fs.rm(cleanupPath, { force: true, recursive: true }); + } + } +}); + +describe("NetworkCapture", () => { + it("preserves the V3 files and keeps its sidecar alive across on/off/on", async () => { + const daemonDir = await fs.mkdtemp( + join(tmpdir(), "browse-network-sidecar-"), + ); + cleanupPaths.push(daemonDir); + const previousDaemonDir = process.env.BROWSE_DAEMON_DIR; + process.env.BROWSE_DAEMON_DIR = daemonDir; + const session = new FakeCdpSession(); + const sidecar = { + attach: vi.fn(async () => session), + close: vi.fn(), + }; + const capture = new NetworkCapture( + "sidecar", + sidecar as unknown as NetworkCdpSidecar, + ); + const originalWriteFile = fs.writeFile.bind(fs); + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (...args) => { + if (String(args[0]).endsWith("request.json")) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return originalWriteFile(...args); + }); + + try { + await expect( + capture.enable({ pageId: "page-1" }, "ws://browser.test"), + ).resolves.toMatchObject({ enabled: true }); + expect(sidecar.attach).toHaveBeenCalledWith( + "ws://browser.test", + "page-1", + ); + + session.emit("Network.requestWillBeSent", { + request: { + headers: { accept: "text/plain" }, + method: "POST", + postData: "hello=world", + url: "https://example.com/fast", + }, + requestId: "request-1", + type: "Fetch", + }); + session.emit("Network.responseReceived", { + requestId: "request-1", + response: { + headers: { "content-type": "text/plain" }, + mimeType: "text/plain", + status: 200, + statusText: "OK", + }, + }); + session.emit("Network.loadingFinished", { requestId: "request-1" }); + + const requestDir = join( + daemonDir, + "sidecar-network", + "000-POST-example.com-fast", + ); + const responsePath = join(requestDir, "response.json"); + await waitForFile(responsePath); + await expect( + readJson(join(requestDir, "request.json")), + ).resolves.toMatchObject({ + body: "hello=world", + method: "POST", + resourceType: "Fetch", + url: "https://example.com/fast", + }); + await expect(readJson(responsePath)).resolves.toMatchObject({ + body: "ok", + mimeType: "text/plain", + status: 200, + statusText: "OK", + }); + + await expect(capture.disable()).resolves.toMatchObject({ + enabled: false, + }); + expect(session.detach).toHaveBeenCalledOnce(); + expect(sidecar.close).not.toHaveBeenCalled(); + + session.connected = true; + await capture.enable({ pageId: "page-2" }, "ws://browser.test"); + expect(sidecar.attach).toHaveBeenLastCalledWith( + "ws://browser.test", + "page-2", + ); + expect(sidecar.attach).toHaveBeenCalledTimes(2); + + session.emit("Network.requestWillBeSent", { + request: { + method: "POST", + url: "https://example.com/fast", + }, + requestId: "request-2", + type: "Fetch", + }); + session.emit("Network.loadingFinished", { requestId: "request-2" }); + await waitForFile( + join( + daemonDir, + "sidecar-network", + "001-POST-example.com-fast", + "response.json", + ), + ); + await expect( + readJson(join(requestDir, "request.json")), + ).resolves.toMatchObject({ + id: "request-1", + }); + + await capture.close(); + expect(sidecar.close).toHaveBeenCalledOnce(); + } finally { + writeFileSpy.mockRestore(); + restoreEnv("BROWSE_DAEMON_DIR", previousDaemonDir); + } + }); + + it("serializes overlapping enable operations", async () => { + const daemonDir = await fs.mkdtemp( + join(tmpdir(), "browse-network-overlap-"), + ); + cleanupPaths.push(daemonDir); + const previousDaemonDir = process.env.BROWSE_DAEMON_DIR; + process.env.BROWSE_DAEMON_DIR = daemonDir; + const session = new FakeCdpSession(); + const sidecar = { + attach: vi.fn(async () => session), + close: vi.fn(), + }; + const capture = new NetworkCapture( + "overlap", + sidecar as unknown as NetworkCdpSidecar, + ); + + try { + const [first, second] = await Promise.all([ + capture.enable({ pageId: "page-1" }, "ws://browser.test"), + capture.enable({ pageId: "page-1" }, "ws://browser.test"), + ]); + + expect(first).toMatchObject({ enabled: true }); + expect(second).toMatchObject({ alreadyEnabled: true, enabled: true }); + expect(sidecar.attach).toHaveBeenCalledOnce(); + } finally { + await capture.close(); + restoreEnv("BROWSE_DAEMON_DIR", previousDaemonDir); + } + }); +}); + +class FakeCdpSession implements NetworkCdpSession { + connected = true; + readonly detach = vi.fn(async () => { + this.connected = false; + }); + private readonly listeners = new Map< + string, + Set<(params: unknown) => void> + >(); + + async send(method: string): Promise { + if (method === "Network.getResponseBody") { + return { body: "ok" } as T; + } + return {} as T; + } + + on(event: string, listener: (params: unknown) => void): void { + const listeners = this.listeners.get(event) ?? new Set(); + listeners.add(listener); + this.listeners.set(event, listeners); + } + + off(event: string, listener: (params: unknown) => void): void { + this.listeners.get(event)?.delete(listener); + } + + emit(event: string, params: unknown): void { + for (const listener of this.listeners.get(event) ?? []) listener(params); + } +} + +async function readJson(filePath: string): Promise> { + return JSON.parse(await fs.readFile(filePath, "utf8")) as Record< + string, + unknown + >; +} + +async function waitForFile(filePath: string): Promise { + const start = Date.now(); + while (Date.now() - start < 1_000) { + try { + await fs.access(filePath); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw new Error(`Timed out waiting for ${filePath}`); +} + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + return; + } + process.env[key] = value; +} diff --git a/packages/cli/tests/network-cdp-sidecar.test.ts b/packages/cli/tests/network-cdp-sidecar.test.ts new file mode 100644 index 0000000000..5692183d28 --- /dev/null +++ b/packages/cli/tests/network-cdp-sidecar.test.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from "node:events"; + +import type WebSocket from "ws"; +import { describe, expect, it, vi } from "vitest"; + +import { NetworkCdpSidecar } from "../src/lib/driver/network-cdp-sidecar.js"; + +describe("NetworkCdpSidecar", () => { + it("reuses one browser WebSocket across detached page sessions", async () => { + const socket = new FakeWebSocket(); + const factory = vi.fn(() => { + queueMicrotask(() => socket.open()); + return socket as unknown as WebSocket; + }); + const sidecar = new NetworkCdpSidecar(factory); + + const first = await sidecar.attach("ws://browser.test", "page-1"); + const listener = vi.fn(); + first.on("Network.requestWillBeSent", listener); + socket.emitCdp({ + method: "Network.requestWillBeSent", + params: { requestId: "request-1" }, + sessionId: "sidecar-session-1", + }); + + expect(listener).toHaveBeenCalledWith({ requestId: "request-1" }); + await expect(first.send("Network.enable")).resolves.toEqual({}); + await first.detach(); + expect(first.connected).toBe(false); + + const second = await sidecar.attach("ws://browser.test", "page-2"); + expect(second.connected).toBe(true); + expect(factory).toHaveBeenCalledOnce(); + expect(socket.sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: "Target.attachToTarget", + params: { flatten: true, targetId: "page-1" }, + }), + expect.objectContaining({ + method: "Target.detachFromTarget", + params: { sessionId: "sidecar-session-1" }, + }), + expect.objectContaining({ + method: "Target.attachToTarget", + params: { flatten: true, targetId: "page-2" }, + }), + ]), + ); + + sidecar.close(); + expect(socket.closed).toBe(true); + }); +}); + +class FakeWebSocket extends EventEmitter { + closed = false; + readyState = 0; + readonly sent: Array<{ + id: number; + method: string; + params: Record; + sessionId?: string; + }> = []; + private sessionCounter = 0; + + open(): void { + this.readyState = 1; + this.emit("open"); + } + + send(raw: string): void { + const message = JSON.parse(raw) as (typeof this.sent)[number]; + this.sent.push(message); + const result = + message.method === "Target.attachToTarget" + ? { sessionId: `sidecar-session-${++this.sessionCounter}` } + : {}; + queueMicrotask(() => this.emitCdp({ id: message.id, result })); + } + + close(): void { + this.closed = true; + this.readyState = 3; + this.emit("close"); + } + + emitCdp(message: Record): void { + this.emit("message", Buffer.from(JSON.stringify(message))); + } +} From 72a4df2aca4ceb74642b5a5fdd1087ee11e61590 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 11 Sep 2026 20:17:44 +0000 Subject: [PATCH 6/8] test(cli): include network wait timeout duration --- packages/cli/tests/network-capture.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/tests/network-capture.test.ts b/packages/cli/tests/network-capture.test.ts index 6fe871351d..6a25da8348 100644 --- a/packages/cli/tests/network-capture.test.ts +++ b/packages/cli/tests/network-capture.test.ts @@ -218,8 +218,9 @@ async function readJson(filePath: string): Promise> { } async function waitForFile(filePath: string): Promise { + const timeoutMs = 1_000; const start = Date.now(); - while (Date.now() - start < 1_000) { + while (Date.now() - start < timeoutMs) { try { await fs.access(filePath); return; @@ -227,7 +228,7 @@ async function waitForFile(filePath: string): Promise { await new Promise((resolve) => setTimeout(resolve, 10)); } } - throw new Error(`Timed out waiting for ${filePath}`); + throw new Error(`Timed out waiting for ${filePath} after ${timeoutMs}ms`); } function restoreEnv(key: string, value: string | undefined): void { From 6813aabaf278721825bb0851b7acee6ebf93dffb Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 22 Sep 2026 19:56:49 +0000 Subject: [PATCH 7/8] test(cli): focus XPath regression coverage on input rejection --- packages/cli/tests/driver-commands.test.ts | 27 ++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index bad8db1325..c33ea0d186 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -503,8 +503,18 @@ describe("driver commands", () => { ).rejects.toBe(actionError); }); - it("rejects removed coordinate XPath fields at the driver boundary", async () => { - const manager = {} as Parameters< + it("rejects unsupported coordinate options before accessing the page or performing mouse input", async () => { + const page = { + click: vi.fn(), + dragAndDrop: vi.fn(), + hover: vi.fn(), + scroll: vi.fn(), + }; + const activePage = vi.fn(async () => page); + const manager = { + activePage, + isCursorOverlayEnabled: vi.fn(() => false), + } as unknown as Parameters< NonNullable<(typeof mouseHandlers)["mouse.click"]> >[0]; @@ -521,6 +531,12 @@ describe("driver commands", () => { /returnXPath/, ); } + + expect(activePage).not.toHaveBeenCalled(); + expect(page.click).not.toHaveBeenCalled(); + expect(page.hover).not.toHaveBeenCalled(); + expect(page.scroll).not.toHaveBeenCalled(); + expect(page.dragAndDrop).not.toHaveBeenCalled(); }); it("enables sidecar network capture", async () => { @@ -859,13 +875,6 @@ describe("driver commands", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Prefer targetId"); }); - it("does not expose the removed coordinate XPath flag", async () => { - for (const command of ["click", "hover", "scroll", "drag"]) { - const result = await runCli(["mouse", command, "--help"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toContain("--return-xpath"); - } - }); }); type FakeTabPage = { From db5e592ce4359e1340ef4e921aad4b98581ee94a Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 22 Sep 2026 20:39:38 +0000 Subject: [PATCH 8/8] test(cli): remove obsolete XPath rejection regression --- packages/cli/tests/driver-commands.test.ts | 36 ---------------------- 1 file changed, 36 deletions(-) diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index c33ea0d186..1991b3c844 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -503,42 +503,6 @@ describe("driver commands", () => { ).rejects.toBe(actionError); }); - it("rejects unsupported coordinate options before accessing the page or performing mouse input", async () => { - const page = { - click: vi.fn(), - dragAndDrop: vi.fn(), - hover: vi.fn(), - scroll: vi.fn(), - }; - const activePage = vi.fn(async () => page); - const manager = { - activePage, - isCursorOverlayEnabled: vi.fn(() => false), - } as unknown as Parameters< - NonNullable<(typeof mouseHandlers)["mouse.click"]> - >[0]; - - for (const [command, params] of [ - ["mouse.click", { returnXPath: true, x: 1, y: 2 }], - ["mouse.hover", { returnXPath: false, x: 1, y: 2 }], - ["mouse.scroll", { deltaX: 0, deltaY: 1, returnXPath: true, x: 1, y: 2 }], - [ - "mouse.drag", - { fromX: 1, fromY: 2, returnXPath: false, toX: 3, toY: 4 }, - ], - ] as const) { - await expect(mouseHandlers[command]!(manager, params)).rejects.toThrow( - /returnXPath/, - ); - } - - expect(activePage).not.toHaveBeenCalled(); - expect(page.click).not.toHaveBeenCalled(); - expect(page.hover).not.toHaveBeenCalled(); - expect(page.scroll).not.toHaveBeenCalled(); - expect(page.dragAndDrop).not.toHaveBeenCalled(); - }); - it("enables sidecar network capture", async () => { const page = {}; const network = {