diff --git a/packages/cli/src/lib/driver/commands/elements.ts b/packages/cli/src/lib/driver/commands/elements.ts index cf5dc448ca..1bbe0f0719 100644 --- a/packages/cli/src/lib/driver/commands/elements.ts +++ b/packages/cli/src/lib/driver/commands/elements.ts @@ -7,13 +7,8 @@ export const elementsHandlers: DriverCommandHandlers = { const { selector } = z .object({ selector: z.string().min(1) }) .parse(params); - const stagehand = await manager.stagehandInstance(); - await stagehand.act({ - arguments: [], - description: "click element", - method: "click", - selector: manager.resolveSelector(selector), - } as never); + const page = await manager.activePage(); + await page.locator(manager.resolveSelector(selector)).click(); return { clicked: true }; }, @@ -25,15 +20,9 @@ export const elementsHandlers: DriverCommandHandlers = { value: z.string(), }) .parse(params); - const stagehand = await manager.stagehandInstance(); - await stagehand.act({ - arguments: [value], - description: "fill element", - method: "fill", - selector: manager.resolveSelector(selector), - } as never); + const page = await manager.activePage(); + await page.locator(manager.resolveSelector(selector)).fill(value); if (pressEnter) { - const page = await manager.activePage(); await page.keyPress("Enter"); } return { filled: true, pressedEnter: pressEnter ?? false }; @@ -48,7 +37,7 @@ export const elementsHandlers: DriverCommandHandlers = { .parse(params); const page = await manager.activePage(); const selected = await page - .deepLocator(manager.resolveSelector(selector)) + .locator(manager.resolveSelector(selector)) .selectOption(values); return { selected }; }, @@ -61,9 +50,7 @@ export const elementsHandlers: DriverCommandHandlers = { }) .parse(params); const page = await manager.activePage(); - await page - .deepLocator(manager.resolveSelector(selector)) - .setInputFiles(files.length === 1 ? files[0]! : files); + await page.locator(manager.resolveSelector(selector)).setInputFiles(files); return { files, uploaded: true }; }, @@ -76,7 +63,7 @@ export const elementsHandlers: DriverCommandHandlers = { .parse(params); const page = await manager.activePage(); await page - .deepLocator(manager.resolveSelector(selector)) + .locator(manager.resolveSelector(selector)) .highlight({ durationMs: durationMs ?? 2000 }); return { highlighted: true }; }, diff --git a/packages/cli/src/lib/driver/commands/keyboard.ts b/packages/cli/src/lib/driver/commands/keyboard.ts index 970025bf02..bbf949d7d8 100644 --- a/packages/cli/src/lib/driver/commands/keyboard.ts +++ b/packages/cli/src/lib/driver/commands/keyboard.ts @@ -12,7 +12,14 @@ export const keyboardHandlers: DriverCommandHandlers = { }) .parse(params); const page = await manager.activePage(); - await page.type(text, { delay, withMistakes: mistakes }); + const options = { + ...(delay === undefined ? {} : { delay }), + ...(mistakes === undefined ? {} : { withMistakes: mistakes }), + }; + await page.type( + text, + Object.keys(options).length === 0 ? undefined : options, + ); return { typed: true }; }, diff --git a/packages/cli/src/lib/driver/commands/mouse.ts b/packages/cli/src/lib/driver/commands/mouse.ts index 1f8bedfaaa..62268b172f 100644 --- a/packages/cli/src/lib/driver/commands/mouse.ts +++ b/packages/cli/src/lib/driver/commands/mouse.ts @@ -15,13 +15,13 @@ export const mouseHandlers: DriverCommandHandlers = { y: z.number(), }) .parse(params); + assertXPathUnavailable(returnXPath); const page = await manager.activePage(); - const xpath = await page.click(x, y, { - button, - clickCount, - returnXpath: returnXPath, + await page.click(x, y, { + ...(button === undefined ? {} : { button }), + ...(clickCount === undefined ? {} : { clickCount }), }); - return returnXPath ? { clicked: true, xpath } : { clicked: true }; + return { clicked: true }; }, async "mouse.hover"(manager, params) { @@ -32,9 +32,10 @@ export const mouseHandlers: DriverCommandHandlers = { y: z.number(), }) .parse(params); + assertXPathUnavailable(returnXPath); const page = await manager.activePage(); - const xpath = await page.hover(x, y, { returnXpath: returnXPath }); - return returnXPath ? { hovered: true, xpath } : { hovered: true }; + await page.hover(x, y); + return { hovered: true }; }, async "mouse.scroll"(manager, params) { @@ -47,11 +48,10 @@ export const mouseHandlers: DriverCommandHandlers = { y: z.number(), }) .parse(params); + assertXPathUnavailable(returnXPath); const page = await manager.activePage(); - const xpath = await page.scroll(x, y, deltaX, deltaY, { - returnXpath: returnXPath, - }); - return returnXPath ? { scrolled: true, xpath } : { scrolled: true }; + await page.scroll(x, y, deltaX, deltaY); + return { scrolled: true }; }, async "mouse.drag"(manager, params) { @@ -67,21 +67,19 @@ export const mouseHandlers: DriverCommandHandlers = { toY: z.number(), }) .parse(params); + assertXPathUnavailable(returnXPath); const page = await manager.activePage(); - const [fromXpath, toXpath] = await page.dragAndDrop( - fromX, - fromY, - toX, - toY, - { - button, - delay, - returnXpath: returnXPath, - steps, - }, - ); - return returnXPath - ? { dragged: true, fromXpath, toXpath, xpath: fromXpath } - : { dragged: true }; + await page.dragAndDrop(fromX, fromY, toX, toY, { + ...(button === undefined ? {} : { button }), + ...(delay === undefined ? {} : { delay }), + ...(steps === undefined ? {} : { steps }), + }); + return { dragged: true }; }, }; + +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/navigation.ts b/packages/cli/src/lib/driver/commands/navigation.ts index 5639314c1d..af52951384 100644 --- a/packages/cli/src/lib/driver/commands/navigation.ts +++ b/packages/cli/src/lib/driver/commands/navigation.ts @@ -18,28 +18,38 @@ export const navigationHandlers: DriverCommandHandlers = { async open(manager, params) { const { timeoutMs, url, waitUntil } = OpenSchema.parse(params); const page = await manager.pageForOpen(); - await page.goto(url, { timeoutMs, waitUntil }); + await page.goto(url, pageNavigationOptions({ timeoutMs, waitUntil })); return manager.openResult(page); }, async reload(manager, params) { const options = NavigationOptionsSchema.parse(params); const page = await manager.activePage(); - await page.reload(options); + await page.reload(pageNavigationOptions(options)); return manager.openResult(page); }, async back(manager, params) { const options = NavigationOptionsSchema.parse(params); const page = await manager.activePage(); - await page.goBack(options); + await page.goBack(pageNavigationOptions(options)); return manager.openResult(page); }, async forward(manager, params) { const options = NavigationOptionsSchema.parse(params); const page = await manager.activePage(); - await page.goForward(options); + await page.goForward(pageNavigationOptions(options)); return manager.openResult(page); }, }; + +function pageNavigationOptions({ + timeoutMs, + waitUntil, +}: z.infer) { + return { + ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }), + ...(waitUntil === undefined ? {} : { waitUntil }), + }; +} diff --git a/packages/cli/src/lib/driver/commands/page-info.ts b/packages/cli/src/lib/driver/commands/page-info.ts index 2641bf33c6..eea0b88936 100644 --- a/packages/cli/src/lib/driver/commands/page-info.ts +++ b/packages/cli/src/lib/driver/commands/page-info.ts @@ -25,11 +25,11 @@ export const pageInfoHandlers: DriverCommandHandlers = { .parse(params); const page = await manager.activePage(); - if (what === "url") return { url: page.url() }; + if (what === "url") return { url: await page.url() }; if (what === "title") return { title: await page.title() }; const target = manager.resolveSelector(selector ?? "body"); - const locator = page.deepLocator(target); + const locator = page.locator(target); if (what === "text") return { text: await locator.textContent() }; if (what === "html") return { html: await locator.innerHtml() }; @@ -53,7 +53,7 @@ export const pageInfoHandlers: DriverCommandHandlers = { }) .parse(params); const page = await manager.activePage(); - const locator = page.deepLocator(manager.resolveSelector(selector)); + const locator = page.locator(manager.resolveSelector(selector)); return check === "visible" ? { visible: await locator.isVisible() } : { checked: await locator.isChecked() }; diff --git a/packages/cli/src/lib/driver/commands/runtime.ts b/packages/cli/src/lib/driver/commands/runtime.ts index b750c687fb..9f06c0fdd9 100644 --- a/packages/cli/src/lib/driver/commands/runtime.ts +++ b/packages/cli/src/lib/driver/commands/runtime.ts @@ -3,6 +3,7 @@ import { promises as fs } from "node:fs"; import { z } from "zod"; import type { DriverCommandHandlers } from "./types.js"; +import { unavailableCursorOverlay } from "./unavailable.js"; export const runtimeHandlers: DriverCommandHandlers = { async screenshot(manager, params) { @@ -26,19 +27,21 @@ export const runtimeHandlers: DriverCommandHandlers = { .parse(params); const page = await manager.activePage(); const buffer = await page.screenshot({ - animations: options.animations, - caret: options.caret, - clip: options.clip, - fullPage: options.fullPage, - quality: options.quality, + ...(options.animations === undefined + ? {} + : { animations: options.animations }), + ...(options.caret === undefined ? {} : { caret: options.caret }), + ...(options.clip === undefined ? {} : { clip: options.clip }), + ...(options.fullPage === undefined ? {} : { fullPage: options.fullPage }), + ...(options.quality === undefined ? {} : { quality: options.quality }), timeout: 10_000, - type: options.type, + ...(options.type === undefined ? {} : { type: options.type }), }); if (options.path) { await fs.writeFile(options.path, buffer); return { saved: options.path }; } - return { base64: buffer.toString("base64") }; + return { base64: Buffer.from(buffer).toString("base64") }; }, async viewport(manager, params) { @@ -86,11 +89,7 @@ export const runtimeHandlers: DriverCommandHandlers = { return { waited: true }; }, - async cursor(manager) { - const page = await manager.activePage(); - await page.enableCursorOverlay(); - return { cursor: "enabled" }; - }, + cursor: unavailableCursorOverlay, }; function parseTimeoutMs(value: string | undefined): number { diff --git a/packages/cli/src/lib/driver/commands/tabs.ts b/packages/cli/src/lib/driver/commands/tabs.ts index 534b3280a5..8692bb112e 100644 --- a/packages/cli/src/lib/driver/commands/tabs.ts +++ b/packages/cli/src/lib/driver/commands/tabs.ts @@ -12,17 +12,16 @@ export const tabHandlers: DriverCommandHandlers = { const { url } = z.object({ url: z.string().optional() }).parse(params); const context = await manager.browserContext(); const page = await context.newPage(url); - context.setActivePage(page); + await context.setActivePage(page); + const pages = await context.pages(); return { active: true, - index: context - .pages() - .findIndex( - (candidate: DriverPage) => candidate.targetId() === page.targetId(), - ), - targetId: page.targetId(), + index: pages.findIndex( + (candidate: DriverPage) => candidate.pageId === page.pageId, + ), + targetId: page.pageId, title: await manager.safeTitle(page), - url: page.url(), + url: await page.url(), }; }, @@ -30,36 +29,36 @@ export const tabHandlers: DriverCommandHandlers = { const { tab } = z.object({ tab: z.string().min(1) }).parse(params); const { index, page } = await resolveTab(manager, tab); const context = await manager.browserContext(); - context.setActivePage(page); + await context.setActivePage(page); return { index, switched: true, - targetId: page.targetId(), + targetId: page.pageId, title: await manager.safeTitle(page), - url: page.url(), + url: await page.url(), }; }, async "tab.close"(manager, params) { const { tab } = z.object({ tab: z.string().optional() }).parse(params); const context = await manager.browserContext(); - const pages = context.pages(); + const pages = await context.pages(); if (pages.length === 1) { throw new Error("Cannot close the last tab."); } - const active = context.activePage(); + const active = await context.activePage(); const resolved = tab ? await resolveTab(manager, tab) : resolveActiveTab(pages, active ?? null); - const closedTargetId = resolved.page.targetId(); - const activeTargetId = active?.targetId(); + const closedTargetId = resolved.page.pageId; + const activeTargetId = active?.pageId; await resolved.page.close(); - const remainingPages = context - .pages() - .filter((page) => page.targetId() !== closedTargetId); + const remainingPages = (await context.pages()).filter( + (page) => page.pageId !== closedTargetId, + ); let selectedPage = activeTargetId - ? remainingPages.find((page) => page.targetId() === activeTargetId) + ? remainingPages.find((page) => page.pageId === activeTargetId) : undefined; if (!selectedPage) { @@ -67,25 +66,27 @@ export const tabHandlers: DriverCommandHandlers = { remainingPages[Math.min(resolved.index, remainingPages.length - 1)] ?? remainingPages[0]; if (selectedPage) { - context.setActivePage(selectedPage); + await context.setActivePage(selectedPage); } } return { closed: true, index: resolved.index, - selectedTargetId: selectedPage?.targetId(), + selectedTargetId: selectedPage?.pageId, targetId: closedTargetId, }; }, }; async function resolveTab( - manager: { browserContext: () => Promise<{ pages: () => DriverPage[] }> }, + manager: { + browserContext: () => Promise<{ pages: () => Promise }>; + }, tab: string, ): Promise<{ index: number; page: DriverPage }> { const context = await manager.browserContext(); - const pages = context.pages(); + const pages = await context.pages(); const index = Number.parseInt(tab, 10); if (/^\d+$/.test(tab)) { const page = pages[index]; @@ -97,7 +98,7 @@ async function resolveTab( } const targetIndex = pages.findIndex( - (page: DriverPage) => page.targetId() === tab, + (page: DriverPage) => page.pageId === tab, ); if (targetIndex === -1) { throw new Error( @@ -111,9 +112,9 @@ function resolveActiveTab( pages: DriverPage[], active: DriverPage | null, ): { index: number; page: DriverPage } { - const activeTargetId = active?.targetId(); + const activeTargetId = active?.pageId; const index = activeTargetId - ? pages.findIndex((page) => page.targetId() === activeTargetId) + ? pages.findIndex((page) => page.pageId === activeTargetId) : 0; const page = pages[index] ?? pages[0]; if (!page) throw new Error("No active tab."); diff --git a/packages/cli/src/lib/driver/commands/unavailable.ts b/packages/cli/src/lib/driver/commands/unavailable.ts new file mode 100644 index 0000000000..6f1f3d86e0 --- /dev/null +++ b/packages/cli/src/lib/driver/commands/unavailable.ts @@ -0,0 +1,9 @@ +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/daemon/client.ts b/packages/cli/src/lib/driver/daemon/client.ts index 60cc96614d..260c56b637 100644 --- a/packages/cli/src/lib/driver/daemon/client.ts +++ b/packages/cli/src/lib/driver/daemon/client.ts @@ -28,6 +28,13 @@ interface OpenViaDaemonOptions { waitUntil?: "load" | "domcontentloaded" | "networkidle"; } +const DEFAULT_DAEMON_REQUEST_TIMEOUT_MS = 35_000; +const DEFAULT_NAVIGATION_TIMEOUT_MS = 30_000; +// Stagehand gives browser/extension initialization 60 seconds. The daemon +// transport must cover that work before the page navigation timeout begins. +const OPEN_INITIALIZATION_ALLOWANCE_MS = 65_000; +const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647; + export async function ensureDriverDaemon({ session, target, @@ -44,7 +51,9 @@ export async function ensureDriverDaemon({ fail( `Timed out waiting for driver daemon lock for session "${session}".`, 1, - { resultCode: "daemon_lock_timeout" }, + { + resultCode: "daemon_lock_timeout", + }, ); } @@ -200,10 +209,12 @@ async function sendDriverRequest( new CommandFailure( `Timed out waiting for driver daemon session "${session}".`, 1, - { resultCode: "daemon_socket_timeout" }, + { + resultCode: "daemon_socket_timeout", + }, ), ); - }, 35_000); + }, daemonRequestTimeoutMs(request)); socket.on("connect", () => { socket.write(`${JSON.stringify(request)}\n`); @@ -248,6 +259,15 @@ async function sendDriverRequest( }); } +export function daemonRequestTimeoutMs(request: DriverRequest): number { + if (request.type !== "open") return DEFAULT_DAEMON_REQUEST_TIMEOUT_MS; + return Math.min( + OPEN_INITIALIZATION_ALLOWANCE_MS + + (request.timeoutMs ?? DEFAULT_NAVIGATION_TIMEOUT_MS), + MAX_NODE_TIMER_DELAY_MS, + ); +} + function spawnDaemon(session: string, target: ConnectionTarget): void { const entrypoint = process.argv[1]; if (!entrypoint) { diff --git a/packages/cli/src/lib/driver/network-capture.ts b/packages/cli/src/lib/driver/network-capture.ts index d2bcb05e61..921f58afb2 100644 --- a/packages/cli/src/lib/driver/network-capture.ts +++ b/packages/cli/src/lib/driver/network-capture.ts @@ -7,6 +7,7 @@ import { getNetworkDir, writePrivateFile, } from "./daemon/paths.js"; +import { DriverError } from "./errors.js"; interface PendingRequest { body: string | null; @@ -34,10 +35,6 @@ type CdpSession = { ) => Promise; }; -type StagehandPageWithMainFrame = { - mainFrame: () => { session: CdpSession }; -}; - export class NetworkCapture { private cdpSession: CdpSession | null = null; private counter = 0; @@ -53,12 +50,14 @@ export class NetworkCapture { constructor(private readonly session: string) {} async enable( - page: StagehandPageWithMainFrame, + page: unknown, ): Promise<{ alreadyEnabled?: boolean; enabled: true; path: string }> { if (this.enabled && this.networkDir) { return { alreadyEnabled: true, enabled: true, path: this.networkDir }; } + const cdpSession = await this.networkCdpSession(page); + await ensureRuntimeDir(); this.networkDir = getNetworkDir(this.session); await ensurePrivateDir(this.networkDir); @@ -68,7 +67,6 @@ export class NetworkCapture { this.requestStartTimes.clear(); this.responseMetadata.clear(); - const cdpSession = page.mainFrame().session; this.cdpSession = cdpSession; await cdpSession.send("Network.enable", { maxResourceBufferSize: 5_000_000, @@ -154,6 +152,14 @@ export class NetworkCapture { 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 { diff --git a/packages/cli/src/lib/driver/remote-types.ts b/packages/cli/src/lib/driver/remote-types.ts index 63181f064b..fffbb45f04 100644 --- a/packages/cli/src/lib/driver/remote-types.ts +++ b/packages/cli/src/lib/driver/remote-types.ts @@ -1,12 +1,23 @@ -import type { Stagehand } from "@browserbasehq/stagehand"; +import type Browserbase from "@browserbasehq/sdk"; +import type { StagehandBrowser } from "@browserbasehq/stagehand"; import type { ForwardedEnv } from "./daemon/forwarded-env.js"; import type { DriverModeFlags } from "./mode.js"; -import type { ConnectionTarget, RemoteConnectionTarget } from "./types.js"; +import type { + BrowserbaseIdentity, + ConnectionTarget, + RemoteConnectionTarget, +} from "./types.js"; -export type StagehandConstructorOptions = ConstructorParameters< - typeof Stagehand ->[0]; +export interface RemoteStagehandOptions { + apiKey: string; + browser: Browserbase.SessionCreateParams; +} + +export interface RemoteBrowserLaunch { + browser: StagehandBrowser; + identity: BrowserbaseIdentity; +} export interface RemoteDoctorResult { ok: boolean; @@ -58,7 +69,17 @@ export interface RemoteCapability { remoteStagehandOptions( target?: RemoteConnectionTarget, forwardedEnv?: ForwardedEnv, - ): Promise; + ): Promise; + /** Launch and attach a Browserbase browser while retaining its public identity. */ + launchRemoteBrowser( + target?: RemoteConnectionTarget, + forwardedEnv?: ForwardedEnv, + ): Promise; + /** Resolve the stable dashboard and best-effort live-view URLs for a session. */ + remoteBrowserbaseIdentity( + sessionId: string, + forwardedEnv?: ForwardedEnv, + ): Promise; /** Map a failed remote `stagehand.init()` to an actionable message + code. */ classifyRemoteInitError(error: unknown): RemoteInitErrorClassification; /** Remediation strings for driver init failures. */ diff --git a/packages/cli/src/lib/driver/remote.disabled.ts b/packages/cli/src/lib/driver/remote.disabled.ts index 30b68f8e7f..2f03b819d4 100644 --- a/packages/cli/src/lib/driver/remote.disabled.ts +++ b/packages/cli/src/lib/driver/remote.disabled.ts @@ -1,10 +1,11 @@ import type { DriverInitHints, + RemoteBrowserLaunch, RemoteDoctorResult, RemoteInitErrorClassification, - StagehandConstructorOptions, + RemoteStagehandOptions, } from "./remote-types.js"; -import type { ConnectionTarget } from "./types.js"; +import type { BrowserbaseIdentity, ConnectionTarget } from "./types.js"; /** * Stub Browserbase capability used by `build:local-only`. It contains no API @@ -29,12 +30,20 @@ export function forwardedEnvKeys(): readonly string[] { return []; } -export async function remoteStagehandOptions(): Promise { +export async function remoteStagehandOptions(): Promise { // Accepts the forwarded-env arg structurally (fewer params is // assignable) without naming it, keeping this stub key-name-free. throw new Error(DISABLED_MESSAGE); } +export async function launchRemoteBrowser(): Promise { + throw new Error(DISABLED_MESSAGE); +} + +export async function remoteBrowserbaseIdentity(): Promise { + return {}; +} + export function classifyRemoteInitError( error: unknown, ): RemoteInitErrorClassification { diff --git a/packages/cli/src/lib/driver/remote.ts b/packages/cli/src/lib/driver/remote.ts index ae50af9345..dba0ce8f69 100644 --- a/packages/cli/src/lib/driver/remote.ts +++ b/packages/cli/src/lib/driver/remote.ts @@ -1,3 +1,5 @@ +import Browserbase from "@browserbasehq/sdk"; +import { browserbase } from "@browserbasehq/stagehand"; import { StatusCodes } from "http-status-codes"; import { @@ -6,14 +8,26 @@ import { toMetadataValue, } from "../identity.js"; import type { ForwardedEnv } from "./daemon/forwarded-env.js"; +import { DriverError } from "./errors.js"; import type { DriverModeFlags } from "./mode.js"; import type { DriverInitHints, + RemoteBrowserLaunch, RemoteDoctorResult, RemoteInitErrorClassification, - StagehandConstructorOptions, + RemoteStagehandOptions, } from "./remote-types.js"; -import type { ConnectionTarget, RemoteConnectionTarget } from "./types.js"; +import type { + BrowserbaseIdentity, + ConnectionTarget, + RemoteConnectionTarget, +} from "./types.js"; + +type BrowserbaseDebugClient = { + sessions: { + debug(sessionId: string): Promise<{ debuggerUrl?: string }>; + }; +}; /** * Real Browserbase capability. This is the ONLY module that reads @@ -49,13 +63,12 @@ export function forwardedEnvKeys(): readonly string[] { export async function remoteStagehandOptions( target?: RemoteConnectionTarget, forwardedEnv?: ForwardedEnv, -): Promise { +): Promise { // Prefer the caller's forwarded key; fall back to the daemon's own spawn-time // env (e.g. a daemon that was started with a key). Threading the value here // avoids writing the key back into the daemon's `process.env`. The project id // is left to Stagehand to resolve (constructor opt → env → inferred from key). - const apiKey = - forwardedEnv?.BROWSERBASE_API_KEY ?? process.env.BROWSERBASE_API_KEY; + const apiKey = resolveApiKey(forwardedEnv); if (!apiKey) { throw new Error( "Missing BROWSERBASE_API_KEY for remote mode. Pass --local to run a managed local browser (no key needed), or set BROWSERBASE_API_KEY for cloud sessions.", @@ -76,18 +89,70 @@ export async function remoteStagehandOptions( return { apiKey, - browserbaseSessionCreateParams: { + browser: { userMetadata, ...(target?.proxies ? { proxies: true } : {}), ...(target?.verified ? { browserSettings: { verified: true } } : {}), }, - disableAPI: true, - disablePino: true, - env: "BROWSERBASE", - verbose: 0, }; } +export async function launchRemoteBrowser( + target?: RemoteConnectionTarget, + forwardedEnv?: ForwardedEnv, +): Promise { + const { apiKey, browser: sessionOptions } = await remoteStagehandOptions( + target, + forwardedEnv, + ); + // The V4 factory provisions the packaged Stagehand extension before it + // creates the Browserbase session and owns both resources on browser.close(). + // A raw sessions.create() + browserbase.connect() skips that provisioning. + let browser; + try { + browser = await browserbase.launch({ apiKey, ...sessionOptions }); + } catch (error) { + throw remoteDriverError(error); + } + return { + browser, + identity: browser.sessionId + ? await remoteBrowserbaseIdentity(browser.sessionId, forwardedEnv) + : {}, + }; +} + +export async function remoteBrowserbaseIdentity( + sessionId: string, + forwardedEnv?: ForwardedEnv, + browserbase?: BrowserbaseDebugClient, +): Promise { + const normalizedSessionId = sessionId.trim(); + if (!normalizedSessionId) return {}; + + const identity: BrowserbaseIdentity = { + browserbaseSessionId: normalizedSessionId, + browserbaseSessionUrl: `https://www.browserbase.com/sessions/${normalizedSessionId}`, + }; + const apiKey = resolveApiKey(forwardedEnv); + if (!apiKey) return identity; + + try { + const client = browserbase ?? new Browserbase({ apiKey }); + const { debuggerUrl } = await client.sessions.debug(normalizedSessionId); + if (debuggerUrl) { + identity.browserbaseDebugUrl = debuggerUrl; + } + } catch { + // The stable session URL and ID are still useful if live-view lookup fails. + } + return identity; +} + +function resolveApiKey(forwardedEnv?: ForwardedEnv): string | undefined { + return forwardedEnv?.BROWSERBASE_API_KEY ?? process.env.BROWSERBASE_API_KEY; +} + /** * Map a failed remote `stagehand.init()` to an actionable message and a * stable result code. Browserbase SDK errors carry an HTTP `status`. @@ -97,8 +162,6 @@ export function classifyRemoteInitError( ): RemoteInitErrorClassification { const status = (error as { status?: unknown } | null | undefined)?.status; const httpStatus = typeof status === "number" ? status : undefined; - const original = error instanceof Error ? error.message : String(error); - if (httpStatus === StatusCodes.UNAUTHORIZED) { return { code: "remote_auth_401", @@ -120,10 +183,16 @@ export function classifyRemoteInitError( return { code: "remote_session_create_failed", ...(httpStatus !== undefined ? { httpStatus } : {}), - message: `Failed to start a remote (Browserbase) session: ${original}\nRun browse doctor to diagnose remote connectivity.`, + message: + "Failed to start a remote (Browserbase) session. Run browse doctor to diagnose remote connectivity.", }; } +function remoteDriverError(error: unknown): DriverError { + const { code, httpStatus, message } = classifyRemoteInitError(error); + return new DriverError(message, { code, httpStatus }); +} + export function driverInitHints(): DriverInitHints { return { chromeNotFound: diff --git a/packages/cli/src/lib/driver/session-manager.ts b/packages/cli/src/lib/driver/session-manager.ts index f8885d5b75..7f30ae3951 100644 --- a/packages/cli/src/lib/driver/session-manager.ts +++ b/packages/cli/src/lib/driver/session-manager.ts @@ -1,4 +1,8 @@ -import { Stagehand } from "@browserbasehq/stagehand"; +import { + localBrowser, + Stagehand, + type StagehandBrowser, +} from "@browserbasehq/stagehand"; import { emptyRefMaps, @@ -23,8 +27,10 @@ import type { PageSummary, } from "./types.js"; -export type DriverContext = Stagehand["context"]; -export type DriverPage = Awaited>; +export type DriverContext = Stagehand["browser"]["context"]; +export type DriverPage = NonNullable< + Awaited> +>; const INIT_FAILURE_RETRY_MS = 5_000; const INIT_FAILURE_RETRY_MAX_MS = 60_000; @@ -62,13 +68,16 @@ export function isChromeNotFoundError(error: unknown): boolean { } return ( error instanceof Error && - error.message.includes("No Chrome installations found") + (error.message.includes("No Chrome installations found") || + error.message.includes("Chrome installation not found; set CHROME_PATH")) ); } export class DriverSessionManager { readonly network: NetworkCapture; + private browser: StagehandBrowser | null = null; + private browserbaseIdentityValue: BrowserbaseIdentity = {}; private consecutiveInitFailures = 0; private context: DriverContext | null = null; private lastForwardedEnvSignature: string | null = null; @@ -165,7 +174,7 @@ export class DriverSessionManager { }; } - const page = this.activePageIfPresent(); + const page = await this.activePageIfPresent(); const pages = await this.pageSummaries(); return { ...this.browserbaseIdentity(), @@ -174,11 +183,11 @@ export class DriverSessionManager { mode: this.target.kind, pages, pid: process.pid, - selectedTargetId: page?.targetId() ?? this.selectedTargetId, + selectedTargetId: page?.pageId ?? this.selectedTargetId, session: this.session, target: this.target, title: page ? await safeTitle(page) : undefined, - url: page?.url(), + url: page ? await page.url() : undefined, }; } @@ -189,33 +198,25 @@ export class DriverSessionManager { * non-remote targets or before the driver has initialized. */ private browserbaseIdentity(): BrowserbaseIdentity { - if (this.target.kind !== "remote" || !this.stagehand) return {}; - const { browserbaseSessionID, browserbaseSessionURL, browserbaseDebugURL } = - this.stagehand; - - const identity: BrowserbaseIdentity = {}; - if (browserbaseSessionID) { - identity.browserbaseSessionId = browserbaseSessionID; - } - if (browserbaseSessionURL) { - identity.browserbaseSessionUrl = browserbaseSessionURL; - } - if (browserbaseDebugURL) { - identity.browserbaseDebugUrl = browserbaseDebugURL; - } - return identity; + return this.target.kind === "remote" ? this.browserbaseIdentityValue : {}; } async close(): Promise { const stagehand = this.stagehand; + const browser = this.browser; this.stagehand = null; + this.browser = null; this.context = null; + this.browserbaseIdentityValue = {}; this.initFailure = null; this.consecutiveInitFailures = 0; await this.network.disable().catch(() => undefined); if (stagehand) { await stagehand.close().catch(() => undefined); } + if (browser) { + await browser.close().catch(() => undefined); + } } resolveSelector(selector: string): string { @@ -231,21 +232,21 @@ export class DriverSessionManager { ...this.browserbaseIdentity(), mode: this.target.kind, pages: await this.pageSummaries(), - selectedTargetId: page.targetId(), + selectedTargetId: page.pageId, session: this.session, title: await this.safeTitle(page), - url: page.url(), + url: await page.url(), }; } async pageSummaries(): Promise { - const pages = this.context?.pages() ?? []; + const pages = this.context ? await this.context.pages() : []; return Promise.all( pages.map(async (page, index) => ({ index, - targetId: page.targetId(), + targetId: page.pageId, title: await this.safeTitle(page), - url: page.url(), + url: await page.url(), })), ); } @@ -264,30 +265,31 @@ export class DriverSessionManager { const target = this.target; if (target.kind === "cdp" && target.targetId) { - const page = this.context - .pages() - .find((candidate) => candidate.targetId() === target.targetId); + const page = (await this.context.pages()).find( + (candidate) => candidate.pageId === target.targetId, + ); if (!page) { throw new Error( `Target ${target.targetId} was not found in the attached browser.`, ); } - this.activateIfNeeded(page); - this.selectedTargetId = page.targetId(); + await this.activateIfNeeded(page); + this.selectedTargetId = page.pageId; return page; } - const existingPage = this.activePageIfPresent() ?? this.context.pages()[0]; + const existingPage = + (await this.activePageIfPresent()) ?? (await this.context.pages())[0]; if (existingPage) { - this.activateIfNeeded(existingPage); - this.selectedTargetId = existingPage.targetId(); + await this.activateIfNeeded(existingPage); + this.selectedTargetId = existingPage.pageId; return existingPage; } if (options.createIfMissing) { const page = await this.context.newPage(); - this.activateIfNeeded(page); - this.selectedTargetId = page.targetId(); + await this.activateIfNeeded(page); + this.selectedTargetId = page.pageId; return page; } @@ -297,9 +299,9 @@ export class DriverSessionManager { ); } - private activePageIfPresent(): DriverPage | undefined { + private async activePageIfPresent(): Promise { try { - return this.context?.activePage() ?? undefined; + return (await this.context?.activePage()) ?? undefined; } catch { return undefined; } @@ -315,9 +317,9 @@ export class DriverSessionManager { * command in headed local mode. Skipping the redundant re-activation keeps a * headed session usable alongside a coding agent. */ - private activateIfNeeded(page: DriverPage): void { - if (page !== this.activePageIfPresent()) { - this.context?.setActivePage(page); + private async activateIfNeeded(page: DriverPage): Promise { + if (page.pageId !== (await this.activePageIfPresent())?.pageId) { + await this.context?.setActivePage(page); } } @@ -366,17 +368,45 @@ export class DriverSessionManager { private async initialize(): Promise { const resolvedTarget = await this.resolveTarget(); - const options = await this.stagehandOptions(resolvedTarget); - const stagehand = new Stagehand(options); + let browser: StagehandBrowser | undefined; try { - await stagehand.init(); + if (resolvedTarget.kind === "remote") { + const remote = await ( + await getRemote() + ).launchRemoteBrowser(resolvedTarget, this.pendingEnv); + browser = remote.browser; + this.browserbaseIdentityValue = remote.identity; + } else if (resolvedTarget.kind === "managed-local") { + browser = await localBrowser.launch({ + ...(resolvedTarget.chromeArgs?.length + ? { args: resolvedTarget.chromeArgs } + : {}), + ...(resolvedTarget.ignoreDefaultArgs === undefined + ? {} + : { ignoreDefaultArgs: resolvedTarget.ignoreDefaultArgs }), + headless: resolvedTarget.headless, + }); + } else if (resolvedTarget.kind === "cdp") { + browser = await localBrowser.connect({ + cdpUrl: resolvedTarget.endpoint, + }); + } else { + throw new Error(`Unsupported target kind: ${resolvedTarget.kind}`); + } + + const stagehand = await Stagehand.create({ + browser, + logging: { level: "off" }, + }); + this.browser = browser; + this.stagehand = stagehand; + this.context = stagehand.browser.context; } catch (error) { - await stagehand.close().catch(() => undefined); + await browser?.close().catch(() => undefined); + this.browserbaseIdentityValue = {}; throw await describeInitError(error, resolvedTarget); } - this.stagehand = stagehand; - this.context = stagehand.context; } private async resolveTarget(): Promise { @@ -391,44 +421,6 @@ export class DriverSessionManager { return { kind: "cdp", endpoint: discovered.wsUrl }; } - - private async stagehandOptions( - target: ConnectionTarget, - ): Promise[0]> { - if (target.kind === "remote") { - return await ( - await getRemote() - ).remoteStagehandOptions(target, this.pendingEnv); - } - - if (target.kind === "managed-local") { - return { - disablePino: true, - env: "LOCAL", - localBrowserLaunchOptions: { - ...(target.chromeArgs?.length ? { args: target.chromeArgs } : {}), - ...(target.ignoreDefaultArgs !== undefined - ? { ignoreDefaultArgs: target.ignoreDefaultArgs } - : {}), - headless: target.headless, - }, - verbose: 0, - }; - } - - if (target.kind === "cdp") { - return { - disablePino: true, - env: "LOCAL", - localBrowserLaunchOptions: { - cdpUrl: target.endpoint, - }, - verbose: 0, - }; - } - - throw new Error(`Unsupported target kind: ${target.kind}`); - } } async function safeTitle(page: DriverPage): Promise { @@ -455,7 +447,7 @@ async function describeInitError( const { code, httpStatus, message } = ( await getRemote() ).classifyRemoteInitError(error); - return new DriverError(message, { cause: error, code, httpStatus }); + return new DriverError(message, { code, httpStatus }); } if (target.kind === "managed-local" && isChromeNotFoundError(error)) { diff --git a/packages/cli/tests/doctor.test.ts b/packages/cli/tests/doctor.test.ts index 435adc3658..895ebd7184 100644 --- a/packages/cli/tests/doctor.test.ts +++ b/packages/cli/tests/doctor.test.ts @@ -360,30 +360,38 @@ describe("doctor report builder", () => { }); it("includes --verified/--proxies in the suggested remote open command", async () => { - const report = await buildDoctorReport( - { - flags: { proxies: true, remote: true, verified: true }, - session: "default", - }, - { - env: { BROWSERBASE_API_KEY: "test-key" }, - getDriverStatus: async () => null, - readPackageVersion: async () => "0.0.0-test", - }, - ); + const daemonDir = await tempDaemonDir(); + const previousDaemonDir = process.env.BROWSE_DAEMON_DIR; - expect(report.verdict).toBe("ok"); - expect(report.checks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - message: "remote (verified, proxies)", - name: "target", - }), - ]), - ); - expect(report.next).toBe( - "browse open https://example.com --remote --verified --proxies", - ); + try { + process.env.BROWSE_DAEMON_DIR = daemonDir; + const report = await buildDoctorReport( + { + flags: { proxies: true, remote: true, verified: true }, + session: "default", + }, + { + env: { BROWSERBASE_API_KEY: "test-key" }, + getDriverStatus: async () => null, + readPackageVersion: async () => "0.0.0-test", + }, + ); + + expect(report.verdict).toBe("ok"); + expect(report.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "remote (verified, proxies)", + name: "target", + }), + ]), + ); + expect(report.next).toBe( + "browse open https://example.com --remote --verified --proxies", + ); + } finally { + restoreEnv("BROWSE_DAEMON_DIR", previousDaemonDir); + } }); }); diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index 9d3afef095..58b9d6e55b 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -5,6 +5,11 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { elementsHandlers } from "../src/lib/driver/commands/elements.js"; +import { keyboardHandlers } from "../src/lib/driver/commands/keyboard.js"; +import { mouseHandlers } from "../src/lib/driver/commands/mouse.js"; +import { navigationHandlers } from "../src/lib/driver/commands/navigation.js"; +import { networkHandlers } from "../src/lib/driver/commands/network.js"; import { resolveSelector } from "../src/lib/driver/commands/selectors.js"; import { formatSnapshotTree } from "../src/lib/driver/commands/snapshot-format.js"; import { snapshotHandlers } from "../src/lib/driver/commands/snapshot.js"; @@ -14,7 +19,6 @@ import { DRIVER_COMMAND_NAMES } from "../src/lib/driver/commands/types.js"; import { hasExplicitDriverTarget } from "../src/lib/driver/command-cli.js"; import { getSocketPath } from "../src/lib/driver/daemon/paths.js"; import { parseRequest } from "../src/lib/driver/daemon/protocol.js"; -import { NetworkCapture } from "../src/lib/driver/network-capture.js"; import { runCli } from "./helpers/run-cli.js"; describe("driver commands", () => { @@ -103,7 +107,9 @@ describe("driver commands", () => { }); await expect( resolveTargetForCommand("reuse-local", { remote: true }), - ).resolves.toEqual({ kind: "remote" }); + ).resolves.toEqual({ + kind: "remote", + }); } finally { vi.doUnmock("../src/lib/driver/daemon/client.js"); vi.resetModules(); @@ -147,7 +153,9 @@ describe("driver commands", () => { expect(runDriverCommandViaDaemon).toHaveBeenCalledWith( "cdp-state", "snapshot", - { compact: true }, + { + compact: true, + }, ); expect(openViaDaemon).not.toHaveBeenCalled(); } finally { @@ -169,6 +177,282 @@ describe("driver commands", () => { ).toThrow(); }); + it("maps the CLI navigation timeout contract to the V4 page option", async () => { + const page = { goto: vi.fn().mockResolvedValue(undefined) }; + const manager = { + openResult: vi.fn().mockResolvedValue({ url: "https://example.com" }), + pageForOpen: vi.fn().mockResolvedValue(page), + } as unknown as Parameters< + NonNullable<(typeof navigationHandlers)["open"]> + >[0]; + + await expect( + navigationHandlers.open!(manager, { + timeoutMs: 5_000, + url: "https://example.com", + waitUntil: "load", + }), + ).resolves.toEqual({ url: "https://example.com" }); + expect(page.goto).toHaveBeenCalledWith("https://example.com", { + timeout: 5_000, + waitUntil: "load", + }); + }); + + it("routes selector click and fill through V4 locators", async () => { + const locator = { + click: vi.fn(), + fill: vi.fn(), + }; + const page = { + keyPress: vi.fn(), + locator: vi.fn(() => locator), + }; + const manager = { + activePage: vi.fn(async () => page), + resolveSelector: vi.fn((selector: string) => + selector === "@0-1" ? "/html/body/button" : selector, + ), + } as unknown as Parameters< + NonNullable<(typeof elementsHandlers)["click"]> + >[0]; + + await expect( + elementsHandlers.click!(manager, { selector: "@0-1" }), + ).resolves.toEqual({ + clicked: true, + }); + await expect( + elementsHandlers.fill!(manager, { + pressEnter: true, + selector: "#email", + value: "user@example.com", + }), + ).resolves.toEqual({ filled: true, pressedEnter: true }); + + expect(page.locator).toHaveBeenNthCalledWith(1, "/html/body/button"); + expect(page.locator).toHaveBeenNthCalledWith(2, "#email"); + expect(locator.click).toHaveBeenCalledOnce(); + expect(locator.fill).toHaveBeenCalledWith("user@example.com"); + expect(page.keyPress).toHaveBeenCalledWith("Enter"); + }); + + it("keeps select and highlight on V4 locators", async () => { + const locator = { + highlight: vi.fn(), + selectOption: vi.fn().mockResolvedValue(["green", "blue"]), + }; + const page = { locator: vi.fn(() => locator) }; + const manager = { + activePage: vi.fn(async () => page), + resolveSelector: vi.fn((selector: string) => selector), + } as unknown as Parameters< + NonNullable<(typeof elementsHandlers)["select"]> + >[0]; + + await expect( + elementsHandlers.select!(manager, { + selector: "#colors", + values: ["green", "blue"], + }), + ).resolves.toEqual({ selected: ["green", "blue"] }); + await expect( + elementsHandlers.highlight!(manager, { + durationMs: 750, + selector: "#colors", + }), + ).resolves.toEqual({ highlighted: true }); + + expect(page.locator).toHaveBeenNthCalledWith(1, "#colors"); + expect(page.locator).toHaveBeenNthCalledWith(2, "#colors"); + expect(locator.selectOption).toHaveBeenCalledWith(["green", "blue"]); + expect(locator.highlight).toHaveBeenCalledWith({ durationMs: 750 }); + }); + + it("uploads files through the V4 locator API", async () => { + const setInputFiles = vi.fn(); + const manager = { + activePage: vi.fn(async () => ({ + locator: vi.fn(() => ({ setInputFiles })), + })), + resolveSelector: vi.fn((selector: string) => selector), + } as unknown as Parameters< + NonNullable<(typeof elementsHandlers)["upload"]> + >[0]; + + await expect( + elementsHandlers.upload!(manager, { + files: ["/tmp/file.txt"], + selector: "input[type=file]", + }), + ).resolves.toEqual({ files: ["/tmp/file.txt"], uploaded: true }); + expect(setInputFiles).toHaveBeenCalledWith(["/tmp/file.txt"]); + }); + + it("omits undefined typing options from V4 requests", async () => { + const page = { type: vi.fn() }; + const manager = { + activePage: vi.fn(async () => page), + } as unknown as Parameters< + NonNullable<(typeof keyboardHandlers)["type"]> + >[0]; + + await expect( + keyboardHandlers.type!(manager, { text: "plain" }), + ).resolves.toEqual({ + typed: true, + }); + await expect( + keyboardHandlers.type!(manager, { + delay: 25, + mistakes: true, + text: "human", + }), + ).resolves.toEqual({ typed: true }); + + expect(page.type).toHaveBeenNthCalledWith(1, "plain", undefined); + expect(page.type).toHaveBeenNthCalledWith(2, "human", { + delay: 25, + withMistakes: true, + }); + }); + + it("preserves supported coordinate mouse arguments on V4 pages", async () => { + const page = { + click: vi.fn(), + dragAndDrop: vi.fn(), + hover: vi.fn(), + scroll: vi.fn(), + }; + const manager = { + activePage: vi.fn(async () => page), + } as unknown as Parameters< + NonNullable<(typeof mouseHandlers)["mouse.click"]> + >[0]; + + await expect( + mouseHandlers["mouse.click"]!(manager, { + button: "right", + clickCount: 2, + x: 10, + y: 20, + }), + ).resolves.toEqual({ clicked: true }); + await expect( + mouseHandlers["mouse.hover"]!(manager, { x: 30, y: 40 }), + ).resolves.toEqual({ + hovered: true, + }); + await expect( + mouseHandlers["mouse.scroll"]!(manager, { + deltaX: 5, + deltaY: 500, + x: 50, + y: 60, + }), + ).resolves.toEqual({ scrolled: true }); + await expect( + mouseHandlers["mouse.drag"]!(manager, { + button: "left", + delay: 25, + fromX: 70, + fromY: 80, + steps: 4, + toX: 90, + toY: 100, + }), + ).resolves.toEqual({ dragged: true }); + + expect(page.click).toHaveBeenCalledWith(10, 20, { + button: "right", + clickCount: 2, + }); + expect(page.hover).toHaveBeenCalledWith(30, 40); + expect(page.scroll).toHaveBeenCalledWith(50, 60, 5, 500); + expect(page.dragAndDrop).toHaveBeenCalledWith(70, 80, 90, 100, { + button: "left", + delay: 25, + steps: 4, + }); + }); + + it("omits undefined coordinate options from V4 requests", async () => { + const page = { + click: vi.fn(), + dragAndDrop: vi.fn(), + hover: vi.fn(), + scroll: vi.fn(), + }; + const manager = { + activePage: vi.fn(async () => page), + } as unknown as Parameters< + NonNullable<(typeof mouseHandlers)["mouse.click"]> + >[0]; + + await mouseHandlers["mouse.click"]!(manager, { x: 10, y: 20 }); + await mouseHandlers["mouse.hover"]!(manager, { x: 30, y: 40 }); + await mouseHandlers["mouse.scroll"]!(manager, { + deltaX: 5, + deltaY: 500, + x: 50, + y: 60, + }); + await mouseHandlers["mouse.drag"]!(manager, { + fromX: 70, + fromY: 80, + toX: 90, + toY: 100, + }); + + expect(page.click).toHaveBeenCalledWith(10, 20, {}); + expect(page.hover).toHaveBeenCalledWith(30, 40); + expect(page.scroll).toHaveBeenCalledWith(50, 60, 5, 500); + expect(page.dragAndDrop).toHaveBeenCalledWith(70, 80, 90, 100, {}); + }); + + it("fails explicitly for the V4 coordinate XPath capability", 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.scroll", { deltaX: 0, deltaY: 1, returnXPath: true, x: 1, y: 2 }], + ["mouse.drag", { fromX: 1, fromY: 2, returnXPath: true, toX: 3, toY: 4 }], + ] as const) { + await expect(mouseHandlers[command]!(manager, params)).rejects.toThrow( + "Coordinate XPath lookup is not exposed by Stagehand V4", + ); + } + }); + + it("reports the isolated V4 network-capture gap", async () => { + const page = {}; + const network = { + enable: vi.fn(async () => { + throw new Error("Network capture is not available"); + }), + }; + const manager = { + activePage: vi.fn(async () => page), + network, + } 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); + }); + + it("keeps cursor as an explicit capability gap", async () => { + await expect( + runtimeHandlers.cursor!({} as never, {}), + ).rejects.toMatchObject({ code: "cursor_overlay_unavailable" }); + }); + it("selects a remaining tab after closing the active tab", async () => { const tabs = createFakeTabManager(["tab-1", "tab-2", "tab-3"], 1); @@ -217,10 +501,46 @@ describe("driver commands", () => { await expect( runtimeHandlers.wait!(manager, { arg: "100", type: "timeout" }), - ).resolves.toEqual({ waited: true }); + ).resolves.toEqual({ + waited: true, + }); expect(page.waitForTimeout).toHaveBeenCalledWith(100); }); + it("omits undefined screenshot options from V4 wire requests", async () => { + const page = { + screenshot: vi.fn().mockResolvedValue(Buffer.from("image")), + }; + const manager = { + activePage: async () => page, + } as unknown as Parameters< + NonNullable<(typeof runtimeHandlers)["screenshot"]> + >[0]; + + await expect(runtimeHandlers.screenshot!(manager, {})).resolves.toEqual({ + base64: Buffer.from("image").toString("base64"), + }); + expect(page.screenshot).toHaveBeenCalledWith({ timeout: 10_000 }); + + await runtimeHandlers.screenshot!(manager, { + animations: "disabled", + caret: "hide", + clip: { height: 200, width: 300, x: 10, y: 20 }, + fullPage: true, + quality: 80, + type: "jpeg", + }); + expect(page.screenshot).toHaveBeenLastCalledWith({ + animations: "disabled", + caret: "hide", + clip: { height: 200, width: 300, x: 10, y: 20 }, + fullPage: true, + quality: 80, + timeout: 10_000, + type: "jpeg", + }); + }); + it("accepts fractional viewport scale values", async () => { const daemonDir = await fs.mkdtemp( join(tmpdir(), "browse-viewport-scale-"), @@ -393,113 +713,13 @@ describe("driver commands", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Prefer targetId"); }); - - it("keeps network responses when loading finishes before request file writes", async () => { - const daemonDir = await fs.mkdtemp(join(tmpdir(), "browse-network-race-")); - const previousDaemonDir = process.env.BROWSE_DAEMON_DIR; - process.env.BROWSE_DAEMON_DIR = daemonDir; - const cdp = new FakeCdpSession(); - const capture = new NetworkCapture("race"); - 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 capture.enable({ mainFrame: () => ({ session: cdp }) }); - cdp.emit("Network.requestWillBeSent", { - request: { - headers: {}, - method: "GET", - url: "https://example.com/fast", - }, - requestId: "req-1", - type: "Document", - }); - cdp.emit("Network.responseReceived", { - requestId: "req-1", - response: { - headers: { "content-type": "text/plain" }, - mimeType: "text/plain", - status: 200, - statusText: "OK", - }, - }); - cdp.emit("Network.loadingFinished", { requestId: "req-1" }); - - const responsePath = join( - daemonDir, - "race-network", - "000-GET-example.com-fast", - "response.json", - ); - await waitForFile(responsePath); - if (process.platform !== "win32") { - const networkDir = join(daemonDir, "race-network"); - const requestDir = join(networkDir, "000-GET-example.com-fast"); - expect(await fileMode(networkDir)).toBe(0o700); - expect(await fileMode(requestDir)).toBe(0o700); - expect(await fileMode(join(requestDir, "request.json"))).toBe(0o600); - expect(await fileMode(responsePath)).toBe(0o600); - } - const response = JSON.parse(await fs.readFile(responsePath, "utf8")) as { - body: string; - status: number; - }; - expect(response).toMatchObject({ body: "ok", status: 200 }); - } finally { - writeFileSpy.mockRestore(); - restoreEnv("BROWSE_DAEMON_DIR", previousDaemonDir); - await fs.rm(daemonDir, { recursive: true, force: true }); - } - }); }); -class FakeCdpSession { - private readonly listeners = new Map< - string, - Array<(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) ?? []; - listeners.push(listener); - this.listeners.set(event, listeners); - } - - off(event: string, listener: (params: unknown) => void): void { - this.listeners.set( - event, - (this.listeners.get(event) ?? []).filter( - (candidate) => candidate !== listener, - ), - ); - } - - emit(event: string, params: unknown): void { - for (const listener of this.listeners.get(event) ?? []) { - listener(params); - } - } -} - type FakeTabPage = { close: ReturnType; - targetId: () => string; + pageId: string; title: () => Promise; - url: () => string; + url: () => Promise; }; function createFakeTabManager(targetIds: string[], activeIndex: number) { @@ -510,9 +730,9 @@ function createFakeTabManager(targetIds: string[], activeIndex: number) { close: vi.fn(async () => { pages = pages.filter((candidate) => candidate !== page); }), - targetId: () => targetId, + pageId: targetId, title: async () => targetId, - url: () => `https://example.com/${targetId}`, + url: async () => `https://example.com/${targetId}`, }; return page; }; @@ -520,9 +740,9 @@ function createFakeTabManager(targetIds: string[], activeIndex: number) { pages = targetIds.map(makePage); active = pages[activeIndex] ?? null; const context = { - activePage: () => active, - pages: () => pages, - setActivePage: vi.fn((page: FakeTabPage) => { + activePage: async () => active, + pages: async () => pages, + setActivePage: vi.fn(async (page: FakeTabPage) => { active = page; }), }; @@ -542,23 +762,6 @@ function createFakeTabManager(targetIds: string[], activeIndex: number) { }; } -async function waitForFile(path: string): Promise { - const start = Date.now(); - while (Date.now() - start < 1000) { - try { - await fs.access(path); - return; - } catch { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } - throw new Error(`Timed out waiting for ${path}`); -} - -async function fileMode(path: string): Promise { - return (await fs.stat(path)).mode & 0o777; -} - function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) { delete process.env[key]; diff --git a/packages/cli/tests/driver-errors.test.ts b/packages/cli/tests/driver-errors.test.ts index b351a09baa..e3d6f209f9 100644 --- a/packages/cli/tests/driver-errors.test.ts +++ b/packages/cli/tests/driver-errors.test.ts @@ -33,20 +33,20 @@ describe("classifyRemoteInitError", () => { expect(classified.message).toContain("browse doctor"); }); - it("preserves the original message for other failures", () => { + it("hides provider details for other failures", () => { const classified = classifyRemoteInitError( - new Error("session quota exceeded"), + new Error("sensitive provider detail"), ); expect(classified.code).toBe("remote_session_create_failed"); expect(classified.httpStatus).toBeUndefined(); - expect(classified.message).toContain("session quota exceeded"); + expect(classified.message).not.toContain("sensitive provider detail"); expect(classified.message).toContain("browse doctor"); }); it("handles non-Error values and non-numeric statuses", () => { const classified = classifyRemoteInitError("boom"); expect(classified.code).toBe("remote_session_create_failed"); - expect(classified.message).toContain("boom"); + expect(classified.message).not.toContain("boom"); }); }); @@ -98,6 +98,11 @@ describe("isChromeNotFoundError", () => { expect( isChromeNotFoundError(new Error("No Chrome installations found.")), ).toBe(true); + expect( + isChromeNotFoundError( + new Error("Chrome installation not found; set CHROME_PATH"), + ), + ).toBe(true); }); it("does not match unrelated errors", () => { diff --git a/packages/cli/tests/driver-foundation.test.ts b/packages/cli/tests/driver-foundation.test.ts index 21136f6c45..f2422f4aea 100644 --- a/packages/cli/tests/driver-foundation.test.ts +++ b/packages/cli/tests/driver-foundation.test.ts @@ -29,6 +29,7 @@ import { sanitizeSessionName, } from "../src/lib/driver/daemon/paths.js"; import { + daemonRequestTimeoutMs, ensureDriverDaemon, getDriverStatus, openViaDaemon, @@ -124,7 +125,9 @@ describe("driver foundation", () => { }); await expect( resolveConnectionTarget({ "auto-connect": true }), - ).resolves.toEqual({ kind: "auto-connect" }); + ).resolves.toEqual({ + kind: "auto-connect", + }); await expect( resolveConnectionTarget({ cdp: "ws://127.0.0.1:9222/devtools/browser/test", @@ -237,10 +240,16 @@ describe("driver foundation", () => { }); await expect( resolveConnectionTarget({ remote: true, verified: true }), - ).resolves.toEqual({ kind: "remote", verified: true }); + ).resolves.toEqual({ + kind: "remote", + verified: true, + }); await expect( resolveConnectionTarget({ proxies: true, remote: true }), - ).resolves.toEqual({ kind: "remote", proxies: true }); + ).resolves.toEqual({ + kind: "remote", + proxies: true, + }); await expect( resolveConnectionTarget({ proxies: true, remote: true, verified: true }), ).resolves.toEqual({ kind: "remote", proxies: true, verified: true }); @@ -969,20 +978,89 @@ describe("driver foundation", () => { } }); - it("closes Stagehand when initialization fails", async () => { - const init = vi.fn().mockRejectedValue(new Error("init failed")); - const close = vi.fn().mockResolvedValue(undefined); - const Stagehand = vi.fn(function () { - return { - close, - context: {}, - init, - }; + it("keeps open requests alive through browser initialization and navigation", async () => { + const daemonDir = await mkdtemp(join(tmpdir(), "browse-driver-test-")); + cleanupPaths.push(daemonDir); + const previousDaemonDir = process.env.BROWSE_DAEMON_DIR; + process.env.BROWSE_DAEMON_DIR = daemonDir; + const session = "slow-open"; + const sockets = new Set(); + let requestReceived: (() => void) | undefined; + const received = new Promise((resolve) => { + requestReceived = resolve; + }); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.once("data", (chunk) => { + const request = JSON.parse(chunk.toString().split("\n")[0] ?? "{}") as { + id: string; + }; + requestReceived?.(); + setTimeout(() => { + socket.end( + `${JSON.stringify({ + data: { url: "https://example.com" }, + id: request.id, + type: "success", + })}\n`, + ); + }, 36_000); + }); }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(getSocketPath(session), resolve); + }); + const open = openViaDaemon(session, "https://example.com", { + timeoutMs: 1_000, + }); + await received; + await vi.advanceTimersByTimeAsync(36_000); + + await expect(open).resolves.toEqual({ url: "https://example.com" }); + } finally { + vi.useRealTimers(); + restoreEnv("BROWSE_DAEMON_DIR", previousDaemonDir); + for (const socket of sockets) { + socket.destroy(); + } + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + }); + + it("caps open transport timeouts at Node's maximum timer delay", () => { + expect( + daemonRequestTimeoutMs({ + id: "large-timeout", + timeoutMs: Number.MAX_SAFE_INTEGER, + type: "open", + url: "https://example.com", + }), + ).toBe(2_147_483_647); + }); + + it("closes the browser when Stagehand.create fails", async () => { + const closeBrowser = vi.fn().mockResolvedValue(undefined); + const browser = { close: closeBrowser, context: {}, origin: "launched" }; + const launch = vi.fn().mockResolvedValue(browser); + const create = vi.fn().mockRejectedValue(new Error("init failed")); + vi.resetModules(); vi.doMock("@browserbasehq/stagehand", () => ({ - Stagehand, + localBrowser: { connect: vi.fn(), launch }, + Stagehand: { create }, })); try { @@ -1000,28 +1078,101 @@ describe("driver foundation", () => { await expect(manager.open("https://example.com")).rejects.toThrow( "init failed", ); - expect(Stagehand).toHaveBeenCalledTimes(1); - expect(init).toHaveBeenCalledTimes(1); - expect(close).toHaveBeenCalledTimes(1); + expect(launch).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledTimes(1); + expect(closeBrowser).toHaveBeenCalledTimes(1); } finally { vi.doUnmock("@browserbasehq/stagehand"); vi.resetModules(); } }); - it("passes Chrome args to managed local Stagehand launches", async () => { - const init = vi.fn().mockResolvedValue(undefined); - const Stagehand = vi.fn(function () { - return { - close: vi.fn().mockResolvedValue(undefined), - context: {}, - init, - }; + it("closes a connected browser when Stagehand.create fails", async () => { + const closeBrowser = vi.fn().mockResolvedValue(undefined); + const browser = { close: closeBrowser, context: {}, origin: "connected" }; + const connect = vi.fn().mockResolvedValue(browser); + const create = vi.fn().mockRejectedValue(new Error("init failed")); + + vi.resetModules(); + vi.doMock("@browserbasehq/stagehand", () => ({ + localBrowser: { connect, launch: vi.fn() }, + Stagehand: { create }, + })); + + try { + const { DriverSessionManager: MockedDriverSessionManager } = await import( + "../src/lib/driver/session-manager.js" + ); + const manager = new MockedDriverSessionManager("connected-init-failure", { + endpoint: "ws://127.0.0.1:9222/devtools/browser/test", + kind: "cdp", + }); + + await expect(manager.open("https://example.com")).rejects.toThrow( + "init failed", + ); + expect(connect).toHaveBeenCalledOnce(); + expect(create).toHaveBeenCalledOnce(); + expect(closeBrowser).toHaveBeenCalledOnce(); + } finally { + vi.doUnmock("@browserbasehq/stagehand"); + vi.resetModules(); + } + }); + + it("closes Stagehand before a connected browser when the manager closes", async () => { + const closeOrder: string[] = []; + const closeBrowser = vi.fn().mockImplementation(async () => { + closeOrder.push("browser"); + }); + const browser = { close: closeBrowser, context: {}, origin: "connected" }; + const connect = vi.fn().mockResolvedValue(browser); + const closeStagehand = vi.fn().mockImplementation(async () => { + closeOrder.push("stagehand"); }); + const create = vi.fn().mockResolvedValue({ + browser, + close: closeStagehand, + }); + + vi.resetModules(); + vi.doMock("@browserbasehq/stagehand", () => ({ + localBrowser: { connect, launch: vi.fn() }, + Stagehand: { create }, + })); + + try { + const { DriverSessionManager: MockedDriverSessionManager } = await import( + "../src/lib/driver/session-manager.js" + ); + const manager = new MockedDriverSessionManager("connected-close", { + endpoint: "ws://127.0.0.1:9222/devtools/browser/test", + kind: "cdp", + }); + + await manager.stagehandInstance(); + await manager.close(); + expect(closeStagehand).toHaveBeenCalledOnce(); + expect(closeBrowser).toHaveBeenCalledOnce(); + expect(closeOrder).toStrictEqual(["stagehand", "browser"]); + } finally { + vi.doUnmock("@browserbasehq/stagehand"); + vi.resetModules(); + } + }); + + it("passes Chrome args to managed local Stagehand launches", async () => { + const browser = { + close: vi.fn().mockResolvedValue(undefined), + context: {}, + }; + const launch = vi.fn().mockResolvedValue(browser); + const create = vi.fn().mockResolvedValue({ browser, close: vi.fn() }); vi.resetModules(); vi.doMock("@browserbasehq/stagehand", () => ({ - Stagehand, + localBrowser: { connect: vi.fn(), launch }, + Stagehand: { create }, })); try { @@ -1036,15 +1187,14 @@ describe("driver foundation", () => { await manager.stagehandInstance(); - expect(Stagehand).toHaveBeenCalledWith( - expect.objectContaining({ - env: "LOCAL", - localBrowserLaunchOptions: { - args: ["--no-focus-on-navigate"], - headless: false, - }, - }), - ); + expect(launch).toHaveBeenCalledWith({ + args: ["--no-focus-on-navigate"], + headless: false, + }); + expect(create).toHaveBeenCalledWith({ + browser, + logging: { level: "off" }, + }); } finally { vi.doUnmock("@browserbasehq/stagehand"); vi.resetModules(); @@ -1052,18 +1202,17 @@ describe("driver foundation", () => { }); it("passes ignored default Chrome args to managed local Stagehand launches", async () => { - const init = vi.fn().mockResolvedValue(undefined); - const Stagehand = vi.fn(function () { - return { - close: vi.fn().mockResolvedValue(undefined), - context: {}, - init, - }; - }); + const browser = { + close: vi.fn().mockResolvedValue(undefined), + context: {}, + }; + const launch = vi.fn().mockResolvedValue(browser); + const create = vi.fn().mockResolvedValue({ browser, close: vi.fn() }); vi.resetModules(); vi.doMock("@browserbasehq/stagehand", () => ({ - Stagehand, + localBrowser: { connect: vi.fn(), launch }, + Stagehand: { create }, })); try { @@ -1078,15 +1227,14 @@ describe("driver foundation", () => { await manager.stagehandInstance(); - expect(Stagehand).toHaveBeenCalledWith( - expect.objectContaining({ - env: "LOCAL", - localBrowserLaunchOptions: { - headless: true, - ignoreDefaultArgs: ["--enable-automation"], - }, - }), - ); + expect(launch).toHaveBeenCalledWith({ + headless: true, + ignoreDefaultArgs: ["--enable-automation"], + }); + expect(create).toHaveBeenCalledWith({ + browser, + logging: { level: "off" }, + }); } finally { vi.doUnmock("@browserbasehq/stagehand"); vi.resetModules(); @@ -1099,18 +1247,17 @@ describe("driver foundation", () => { kind: "managed-local", }); const page = { - targetId: () => "created-target", + pageId: "created-target", }; const pages: (typeof page)[] = []; const context = { - activePage: vi.fn(() => undefined), - awaitActivePage: vi.fn(), + activePage: vi.fn(async () => undefined), newPage: vi.fn(async () => { pages.push(page); return page; }), - pages: vi.fn(() => pages), - setActivePage: vi.fn(), + pages: vi.fn(async () => pages), + setActivePage: vi.fn(async () => undefined), }; vi.spyOn( @@ -1120,7 +1267,6 @@ describe("driver foundation", () => { Object.assign(manager, { context }); await expect(manager.pageForOpen()).resolves.toBe(page); - expect(context.awaitActivePage).not.toHaveBeenCalled(); expect(context.newPage).toHaveBeenCalledOnce(); expect(context.setActivePage).toHaveBeenCalledWith(page); }); @@ -1131,13 +1277,12 @@ describe("driver foundation", () => { kind: "managed-local", }); const page = { - targetId: () => "existing-target", + pageId: "existing-target", }; const context = { - activePage: vi.fn(() => undefined), - awaitActivePage: vi.fn(), - pages: vi.fn(() => [page]), - setActivePage: vi.fn(), + activePage: vi.fn(async () => undefined), + pages: vi.fn(async () => [page]), + setActivePage: vi.fn(async () => undefined), }; vi.spyOn( @@ -1147,7 +1292,6 @@ describe("driver foundation", () => { Object.assign(manager, { context }); await expect(manager.activePage()).resolves.toBe(page); - expect(context.awaitActivePage).not.toHaveBeenCalled(); expect(context.setActivePage).toHaveBeenCalledWith(page); }); @@ -1157,15 +1301,15 @@ describe("driver foundation", () => { kind: "managed-local", }); const page = { - targetId: () => "page-1", + pageId: "page-1", title: vi.fn(async () => "Example"), - url: () => "https://example.com", + url: async () => "https://example.com", }; const context = { - activePage: vi.fn(() => { + activePage: vi.fn(async () => { throw new Error("No Page found for awaitActivePage: no page available"); }), - pages: vi.fn(() => [page]), + pages: vi.fn(async () => [page]), }; Object.assign(manager, { context, stagehand: {} }); @@ -1195,9 +1339,9 @@ describe("driver foundation", () => { ).mockResolvedValue(); Object.assign(manager, { context: { - pages: () => [ + pages: async () => [ { - targetId: () => "different-target", + pageId: "different-target", }, ], }, diff --git a/packages/cli/tests/identity-attribution.test.ts b/packages/cli/tests/identity-attribution.test.ts index 504e744c27..8e114d52d5 100644 --- a/packages/cli/tests/identity-attribution.test.ts +++ b/packages/cli/tests/identity-attribution.test.ts @@ -102,10 +102,7 @@ describe("remoteStagehandOptions — userMetadata", () => { "../src/lib/driver/remote.js" ); const opts = await remoteStagehandOptions(); - const meta = opts.browserbaseSessionCreateParams?.userMetadata as Record< - string, - string - >; + const meta = opts.browser?.userMetadata as Record; expect(meta).toBeDefined(); expect(meta.browse_cli).toBe("true"); @@ -129,10 +126,7 @@ describe("remoteStagehandOptions — userMetadata", () => { "../src/lib/driver/remote.js" ); const opts = await remoteStagehandOptions(); - const meta = opts.browserbaseSessionCreateParams?.userMetadata as Record< - string, - string - >; + const meta = opts.browser?.userMetadata as Record; expect(meta.install_id).toBeDefined(); // Only allowed chars; UUID hyphens are fine @@ -160,10 +154,7 @@ describe("remoteStagehandOptions — userMetadata", () => { "../src/lib/driver/remote.js" ); const opts = await remoteStagehandOptions(); - const meta = opts.browserbaseSessionCreateParams?.userMetadata as Record< - string, - string - >; + const meta = opts.browser?.userMetadata as Record; expect(meta.browse_cli).toBe("true"); expect(typeof meta.cli_version).toBe("string"); diff --git a/packages/cli/tests/remote-disabled.test.ts b/packages/cli/tests/remote-disabled.test.ts index 38b36fb407..4839e33e70 100644 --- a/packages/cli/tests/remote-disabled.test.ts +++ b/packages/cli/tests/remote-disabled.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; import { autoSelectRemoteTarget, forwardedEnvKeys, + launchRemoteBrowser, + remoteBrowserbaseIdentity, remoteDoctorCheck, remoteStagehandOptions, resolveExplicitRemoteTarget, @@ -24,6 +26,10 @@ describe("remote.disabled (local-only capability)", () => { await expect(remoteStagehandOptions()).rejects.toThrow(/disabled/i); }); + it("refuses to launch a remote browser", async () => { + await expect(launchRemoteBrowser()).rejects.toThrow(/disabled/i); + }); + it("reports remote as disabled in doctor without reading any key", () => { const result = remoteDoctorCheck(); expect(result.ok).toBe(true); @@ -34,6 +40,10 @@ describe("remote.disabled (local-only capability)", () => { expect(forwardedEnvKeys()).toEqual([]); }); + it("does not resolve Browserbase identity", async () => { + await expect(remoteBrowserbaseIdentity()).resolves.toEqual({}); + }); + it("contains no BROWSERBASE_API_KEY reference in its source", async () => { const { readFile } = await import("node:fs/promises"); const { fileURLToPath } = await import("node:url"); diff --git a/packages/cli/tests/remote-options.test.ts b/packages/cli/tests/remote-options.test.ts index 2a0ddefddc..454c246603 100644 --- a/packages/cli/tests/remote-options.test.ts +++ b/packages/cli/tests/remote-options.test.ts @@ -1,6 +1,24 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const stagehandMocks = vi.hoisted(() => ({ + launch: vi.fn(), +})); +const browserbaseSdkMocks = vi.hoisted(() => ({ + debug: vi.fn(), +})); + +vi.mock("@browserbasehq/stagehand", () => ({ + browserbase: { launch: stagehandMocks.launch }, +})); +vi.mock("@browserbasehq/sdk", () => ({ + default: class Browserbase { + sessions = { debug: browserbaseSdkMocks.debug }; + }, +})); import { + launchRemoteBrowser, + remoteBrowserbaseIdentity, remoteStagehandOptions, resolveExplicitRemoteTarget, } from "../src/lib/driver/remote.js"; @@ -12,6 +30,8 @@ describe("remote.ts (Browserbase capability)", () => { const previousApiKey = process.env.BROWSERBASE_API_KEY; beforeEach(() => { + stagehandMocks.launch.mockReset(); + browserbaseSdkMocks.debug.mockReset(); process.env.BROWSERBASE_API_KEY = "test-key"; }); @@ -40,9 +60,8 @@ describe("remote.ts (Browserbase capability)", () => { // verified by identity-attribution.test.ts; here we assert only that the // attribution tag survives and that --verified/--proxies are threaded. it("keeps the browse_cli tag and adds no session settings by default", async () => { - const params = (await remoteStagehandOptions({ kind: "remote" })) - .browserbaseSessionCreateParams; - expect((params?.userMetadata as Record).browse_cli).toBe( + const params = (await remoteStagehandOptions({ kind: "remote" })).browser; + expect((params.userMetadata as Record).browse_cli).toBe( "true", ); expect(params).not.toHaveProperty("proxies"); @@ -52,16 +71,16 @@ describe("remote.ts (Browserbase capability)", () => { it("threads proxies alone without touching browserSettings", async () => { const params = ( await remoteStagehandOptions({ kind: "remote", proxies: true }) - ).browserbaseSessionCreateParams; - expect(params?.proxies).toBe(true); + ).browser; + expect(params.proxies).toBe(true); expect(params).not.toHaveProperty("browserSettings"); }); it("threads verified alone into browserSettings without proxies", async () => { const params = ( await remoteStagehandOptions({ kind: "remote", verified: true }) - ).browserbaseSessionCreateParams; - expect(params?.browserSettings).toEqual({ verified: true }); + ).browser; + expect(params.browserSettings).toEqual({ verified: true }); expect(params).not.toHaveProperty("proxies"); }); @@ -72,7 +91,7 @@ describe("remote.ts (Browserbase capability)", () => { proxies: true, verified: true, }) - ).browserbaseSessionCreateParams; + ).browser; expect(params?.proxies).toBe(true); expect(params?.browserSettings).toEqual({ verified: true }); }); @@ -83,4 +102,64 @@ describe("remote.ts (Browserbase capability)", () => { /BROWSERBASE_API_KEY/, ); }); + + it("launches through the V4 Browserbase factory so the extension is provisioned", async () => { + const browser = { close: vi.fn(), sessionId: "session-test" }; + stagehandMocks.launch.mockResolvedValue(browser); + browserbaseSdkMocks.debug.mockResolvedValue({ + debuggerUrl: "https://www.browserbase.com/live/session-test", + }); + + await expect( + launchRemoteBrowser({ kind: "remote", proxies: true, verified: true }), + ).resolves.toEqual({ + browser, + identity: { + browserbaseDebugUrl: "https://www.browserbase.com/live/session-test", + browserbaseSessionId: "session-test", + browserbaseSessionUrl: + "https://www.browserbase.com/sessions/session-test", + }, + }); + expect(stagehandMocks.launch).toHaveBeenCalledWith({ + apiKey: "test-key", + browserSettings: { verified: true }, + proxies: true, + userMetadata: expect.objectContaining({ browse_cli: "true" }), + }); + }); + + it("sanitizes Browserbase launch failures", async () => { + stagehandMocks.launch.mockRejectedValue( + new Error("sensitive provider detail"), + ); + + const error = await launchRemoteBrowser({ kind: "remote" }).catch( + (cause: unknown) => cause, + ); + expect(error).toMatchObject({ + code: "remote_session_create_failed", + name: "DriverError", + }); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).not.toContain("sensitive provider detail"); + expect((error as Error & { cause?: unknown }).cause).toBeUndefined(); + }); + + it("preserves remote session and live-view output fields", async () => { + const debug = async () => ({ + debuggerUrl: "https://www.browserbase.com/live/session-test", + }); + + await expect( + remoteBrowserbaseIdentity(" session-test ", undefined, { + sessions: { debug }, + }), + ).resolves.toEqual({ + browserbaseDebugUrl: "https://www.browserbase.com/live/session-test", + browserbaseSessionId: "session-test", + browserbaseSessionUrl: + "https://www.browserbase.com/sessions/session-test", + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 840ba4b10e..07bebd10de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -357,7 +357,6 @@ catalogs: version: 5.0.14 overrides: - browse@0.9.6>@browserbasehq/stagehand: 3.7.1 vite: 8.1.3 importers: @@ -440,8 +439,8 @@ importers: specifier: ^2.14.0 version: 2.16.0 '@browserbasehq/stagehand': - specifier: 3.7.1 - version: 3.7.1(playwright-core@1.56.1)(zod@4.4.3) + specifier: workspace:* + version: link:../sdk-ts '@oclif/core': specifier: ^4.11.0 version: 4.13.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2c31d9e1f2..e4e95b596e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -57,7 +57,6 @@ catalog: publint: ^0.3.8 smol-toml: 1.7.0 overrides: - "browse@0.9.6>@browserbasehq/stagehand": 3.7.1 vite: "catalog:" allowBuilds: "@ast-grep/lang-go": true diff --git a/turbo.json b/turbo.json index 2ceb42fb72..f1cdc1a558 100644 --- a/turbo.json +++ b/turbo.json @@ -32,9 +32,13 @@ "outputs": ["dist/**"] }, "browse#build": { + "dependsOn": ["@browserbasehq/stagehand#build"], "inputs": ["$TURBO_DEFAULT$", "!dist/**", "!oclif.manifest.json"], "outputs": ["dist/**", "oclif.manifest.json"] }, + "browse#lint": { + "dependsOn": ["@browserbasehq/stagehand#build"] + }, "@browserbasehq/stagehand-integrations#build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", "!dist/**"],