From 7d7024e170dc7b679d19380faca1d37896263c24 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 30 Jul 2026 13:06:29 -0700 Subject: [PATCH 1/7] refactor(cli): port core browser plumbing to V4 --- .../cli/src/lib/driver/commands/navigation.ts | 15 +- .../cli/src/lib/driver/commands/page-info.ts | 6 +- packages/cli/src/lib/driver/commands/tabs.ts | 47 +++--- packages/cli/src/lib/driver/remote-types.ts | 28 +++- .../cli/src/lib/driver/remote.disabled.ts | 15 +- packages/cli/src/lib/driver/remote.ts | 83 ++++++++- .../cli/src/lib/driver/session-manager.ts | 157 +++++++++--------- packages/cli/tests/driver-commands.test.ts | 35 +++- packages/cli/tests/driver-foundation.test.ts | 124 ++++++-------- .../cli/tests/identity-attribution.test.ts | 6 +- packages/cli/tests/remote-disabled.test.ts | 10 ++ packages/cli/tests/remote-options.test.ts | 33 +++- 12 files changed, 339 insertions(+), 220 deletions(-) diff --git a/packages/cli/src/lib/driver/commands/navigation.ts b/packages/cli/src/lib/driver/commands/navigation.ts index 1eb546852c..5d1e4a9f09 100644 --- a/packages/cli/src/lib/driver/commands/navigation.ts +++ b/packages/cli/src/lib/driver/commands/navigation.ts @@ -16,28 +16,35 @@ 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 a1e9169978..cabfd3efde 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/tabs.ts b/packages/cli/src/lib/driver/commands/tabs.ts index 427d716139..8233476778 100644 --- a/packages/cli/src/lib/driver/commands/tabs.ts +++ b/packages/cli/src/lib/driver/commands/tabs.ts @@ -11,16 +11,15 @@ export const tabHandlers: DriverCommandHandlers = { async "tab.new"(manager, params) { const { url } = z.object({ url: z.string().optional() }).parse(params); const context = await manager.browserContext(); - const page = await context.newPage(url); - context.setActivePage(page); + const page = await context.newPage(url ? { url } : {}); + 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(), }; }, @@ -28,57 +27,59 @@ 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) { selectedPage = 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]; @@ -86,7 +87,7 @@ async function resolveTab( return { index, page }; } - const targetIndex = pages.findIndex((page: DriverPage) => page.targetId() === tab); + const targetIndex = pages.findIndex((page: DriverPage) => page.pageId === tab); if (targetIndex === -1) { throw new Error(`Tab targetId ${tab} was not found. Run browse tab list for current tabs.`); } @@ -97,8 +98,8 @@ function resolveActiveTab( pages: DriverPage[], active: DriverPage | null, ): { index: number; page: DriverPage } { - const activeTargetId = active?.targetId(); - const index = activeTargetId ? pages.findIndex((page) => page.targetId() === activeTargetId) : 0; + const activeTargetId = active?.pageId; + const index = activeTargetId ? pages.findIndex((page) => page.pageId === activeTargetId) : 0; const page = pages[index] ?? pages[0]; if (!page) throw new Error("No active tab."); return { index: index >= 0 ? index : 0, page }; diff --git a/packages/cli/src/lib/driver/remote-types.ts b/packages/cli/src/lib/driver/remote-types.ts index 2e90a72dd6..bd7a91415b 100644 --- a/packages/cli/src/lib/driver/remote-types.ts +++ b/packages/cli/src/lib/driver/remote-types.ts @@ -1,10 +1,20 @@ -import type { Stagehand } from "stagehand-v3"; +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[0]; +export interface RemoteStagehandOptions { + apiKey: string; + browser: Browserbase.SessionCreateParams; +} + +export interface RemoteBrowserLaunch { + browser: StagehandBrowser; + identity: BrowserbaseIdentity; + release: () => Promise; +} export interface RemoteDoctorResult { ok: boolean; @@ -56,7 +66,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 09f153d637..234d0d67d0 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 { // Remote targets cannot be selected in a local-only build, so this is only // reachable if a remote error somehow surfaces anyway; preserve it as-is. diff --git a/packages/cli/src/lib/driver/remote.ts b/packages/cli/src/lib/driver/remote.ts index 3f4192c10c..1c91cd049b 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 { getCliVersion, resolveInstallId, toMetadataValue } from "../identity.js"; @@ -5,11 +7,18 @@ import type { ForwardedEnv } from "./daemon/forwarded-env.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 @@ -43,12 +52,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.", @@ -69,18 +78,74 @@ 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); + const client = new Browserbase({ apiKey }); + const session = await client.sessions.create(sessionOptions); + const sessionId = session.id?.trim(); + if (!sessionId) { + throw new Error("Browserbase session creation returned an unexpected shape."); + } + + const release = async () => { + await client.sessions.update(sessionId, { status: "REQUEST_RELEASE" }); + }; + + try { + const browser = await browserbase.connect({ apiKey, sessionId }); + return { + browser, + identity: await remoteBrowserbaseIdentity(sessionId, forwardedEnv, client), + release, + }; + } catch (error) { + await release().catch(() => undefined); + throw error; + } +} + +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`. diff --git a/packages/cli/src/lib/driver/session-manager.ts b/packages/cli/src/lib/driver/session-manager.ts index 9a8154fcbe..f20f43b5ef 100644 --- a/packages/cli/src/lib/driver/session-manager.ts +++ b/packages/cli/src/lib/driver/session-manager.ts @@ -1,4 +1,4 @@ -import { Stagehand } from "stagehand-v3"; +import { localBrowser, Stagehand, type StagehandBrowser } from "@browserbasehq/stagehand"; import { emptyRefMaps, @@ -20,8 +20,8 @@ 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>>; const INIT_FAILURE_RETRY_MS = 5_000; const INIT_FAILURE_RETRY_MAX_MS = 60_000; @@ -60,6 +60,8 @@ export function isChromeNotFoundError(error: unknown): boolean { 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; @@ -69,6 +71,7 @@ export class DriverSessionManager { private refMaps: RefMaps = emptyRefMaps(); private selectedTargetId: string | undefined; private stagehand: Stagehand | null = null; + private releaseRemote: (() => Promise) | null = null; constructor( private readonly session: string, @@ -153,7 +156,7 @@ export class DriverSessionManager { }; } - const page = this.activePageIfPresent(); + const page = await this.activePageIfPresent(); const pages = await this.pageSummaries(); return { ...this.browserbaseIdentity(), @@ -162,11 +165,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, }; } @@ -177,32 +180,30 @@ 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; + const releaseRemote = this.releaseRemote; this.stagehand = null; + this.browser = null; this.context = null; + this.releaseRemote = 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); + } + if (releaseRemote) { + await releaseRemote().catch(() => undefined); + } } resolveSelector(selector: string): string { @@ -218,21 +219,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(), })), ); } @@ -249,28 +250,28 @@ 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; } @@ -280,9 +281,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; } @@ -298,9 +299,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); } } @@ -348,17 +349,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; + let releaseRemote: (() => Promise) | undefined; try { - await stagehand.init(); + if (resolvedTarget.kind === "remote") { + const remote = await ( + await getRemote() + ).launchRemoteBrowser(resolvedTarget, this.pendingEnv); + browser = remote.browser; + releaseRemote = remote.release; + 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.releaseRemote = releaseRemote ?? null; + this.stagehand = stagehand; + this.context = stagehand.browser.context; } catch (error) { - await stagehand.close().catch(() => undefined); + await browser?.close().catch(() => undefined); + await releaseRemote?.().catch(() => undefined); + this.browserbaseIdentityValue = {}; throw await describeInitError(error, resolvedTarget); } - this.stagehand = stagehand; - this.context = stagehand.context; } private async resolveTarget(): Promise { @@ -373,42 +402,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 { diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index 425daabde7..964ccbf935 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { navigationHandlers } from "../src/lib/driver/commands/navigation.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"; @@ -146,6 +147,26 @@ 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>[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("selects a remaining tab after closing the active tab", async () => { const tabs = createFakeTabManager(["tab-1", "tab-2", "tab-3"], 1); @@ -457,9 +478,9 @@ class FakeCdpSession { type FakeTabPage = { close: ReturnType; - targetId: () => string; + pageId: string; title: () => Promise; - url: () => string; + url: () => Promise; }; function createFakeTabManager(targetIds: string[], activeIndex: number) { @@ -470,9 +491,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; }; @@ -480,9 +501,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; }), }; diff --git a/packages/cli/tests/driver-foundation.test.ts b/packages/cli/tests/driver-foundation.test.ts index 057b5e47d4..6a81b9743e 100644 --- a/packages/cli/tests/driver-foundation.test.ts +++ b/packages/cli/tests/driver-foundation.test.ts @@ -668,20 +668,16 @@ 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("closes the browser when Stagehand.create fails", async () => { + const closeBrowser = vi.fn().mockResolvedValue(undefined); + const browser = { close: closeBrowser, context: {} }; + const launch = vi.fn().mockResolvedValue(browser); + const create = vi.fn().mockRejectedValue(new Error("init failed")); vi.resetModules(); - vi.doMock("stagehand-v3", () => ({ - Stagehand, + vi.doMock("@browserbasehq/stagehand", () => ({ + localBrowser: { connect: vi.fn(), launch }, + Stagehand: { create }, })); try { @@ -694,28 +690,24 @@ describe("driver foundation", () => { await expect(manager.open("https://example.com")).rejects.toThrow("init failed"); 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("stagehand-v3"); + 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, - }; - }); + 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("stagehand-v3", () => ({ - Stagehand, + vi.doMock("@browserbasehq/stagehand", () => ({ + localBrowser: { connect: vi.fn(), launch }, + Stagehand: { create }, })); try { @@ -729,34 +721,26 @@ 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("stagehand-v3"); + vi.doUnmock("@browserbasehq/stagehand"); vi.resetModules(); } }); 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("stagehand-v3", () => ({ - Stagehand, + vi.doMock("@browserbasehq/stagehand", () => ({ + localBrowser: { connect: vi.fn(), launch }, + Stagehand: { create }, })); try { @@ -770,17 +754,13 @@ 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("stagehand-v3"); + vi.doUnmock("@browserbasehq/stagehand"); vi.resetModules(); } }); @@ -791,18 +771,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( @@ -812,7 +791,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); }); @@ -823,13 +801,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( @@ -839,7 +816,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); }); @@ -849,15 +825,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: {} }); @@ -885,9 +861,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 d067d86cfd..f8b45e6bf1 100644 --- a/packages/cli/tests/identity-attribution.test.ts +++ b/packages/cli/tests/identity-attribution.test.ts @@ -100,7 +100,7 @@ describe("remoteStagehandOptions — userMetadata", () => { const { remoteStagehandOptions } = await import("../src/lib/driver/remote.js"); const opts = await remoteStagehandOptions(); - const meta = opts.browserbaseSessionCreateParams?.userMetadata as Record; + const meta = opts.browser?.userMetadata as Record; expect(meta).toBeDefined(); expect(meta.browse_cli).toBe("true"); @@ -122,7 +122,7 @@ describe("remoteStagehandOptions — userMetadata", () => { try { const { remoteStagehandOptions } = await import("../src/lib/driver/remote.js"); const opts = await remoteStagehandOptions(); - const meta = opts.browserbaseSessionCreateParams?.userMetadata as Record; + const meta = opts.browser?.userMetadata as Record; expect(meta.install_id).toBeDefined(); // Only allowed chars; UUID hyphens are fine @@ -146,7 +146,7 @@ describe("remoteStagehandOptions — userMetadata", () => { try { const { remoteStagehandOptions } = await import("../src/lib/driver/remote.js"); const opts = await remoteStagehandOptions(); - const meta = opts.browserbaseSessionCreateParams?.userMetadata as Record; + 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 f7de8cf4bc..3f071cc2bf 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 88832a61c3..15a9733ec3 100644 --- a/packages/cli/tests/remote-options.test.ts +++ b/packages/cli/tests/remote-options.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { remoteStagehandOptions, resolveExplicitRemoteTarget } from "../src/lib/driver/remote.js"; +import { + remoteBrowserbaseIdentity, + remoteStagehandOptions, + resolveExplicitRemoteTarget, +} from "../src/lib/driver/remote.js"; // The real Browserbase capability: --verified/--proxies must reach the // session-create params so the cloud session is actually Verified/proxied, @@ -37,23 +41,20 @@ 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; + const params = (await remoteStagehandOptions({ kind: "remote" })).browser; expect((params?.userMetadata as Record).browse_cli).toBe("true"); expect(params).not.toHaveProperty("proxies"); expect(params).not.toHaveProperty("browserSettings"); }); it("threads proxies alone without touching browserSettings", async () => { - const params = (await remoteStagehandOptions({ kind: "remote", proxies: true })) - .browserbaseSessionCreateParams; + const params = (await remoteStagehandOptions({ kind: "remote", proxies: 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; + const params = (await remoteStagehandOptions({ kind: "remote", verified: true })).browser; expect(params?.browserSettings).toEqual({ verified: true }); expect(params).not.toHaveProperty("proxies"); }); @@ -65,7 +66,7 @@ describe("remote.ts (Browserbase capability)", () => { proxies: true, verified: true, }) - ).browserbaseSessionCreateParams; + ).browser; expect(params?.proxies).toBe(true); expect(params?.browserSettings).toEqual({ verified: true }); }); @@ -74,4 +75,20 @@ describe("remote.ts (Browserbase capability)", () => { delete process.env.BROWSERBASE_API_KEY; await expect(remoteStagehandOptions({ kind: "remote" })).rejects.toThrow(/BROWSERBASE_API_KEY/); }); + + 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", + }); + }); }); From 4afd058be610bd11104e7cf17b1a21869985da5b Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 6 Aug 2026 02:02:15 -0700 Subject: [PATCH 2/7] fix(cli): allow time for browser initialization --- .changeset/calm-pandas-wait.md | 5 ++ packages/cli/src/lib/driver/daemon/client.ts | 13 ++++- packages/cli/tests/driver-foundation.test.ts | 60 ++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 .changeset/calm-pandas-wait.md diff --git a/.changeset/calm-pandas-wait.md b/.changeset/calm-pandas-wait.md new file mode 100644 index 0000000000..1ac3c7675d --- /dev/null +++ b/.changeset/calm-pandas-wait.md @@ -0,0 +1,5 @@ +--- +"browse": patch +--- + +Keep CLI open requests alive while Stagehand initializes local or remote browser sessions. diff --git a/packages/cli/src/lib/driver/daemon/client.ts b/packages/cli/src/lib/driver/daemon/client.ts index b0e208deda..88dc40402e 100644 --- a/packages/cli/src/lib/driver/daemon/client.ts +++ b/packages/cli/src/lib/driver/daemon/client.ts @@ -28,6 +28,12 @@ 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; + export async function ensureDriverDaemon({ session, target }: EnsureDaemonOptions): Promise { await ensureRuntimeDir(); const existing = await tryDriverStatus(session); @@ -181,7 +187,7 @@ async function sendDriverRequest(session: string, request: DriverRequest): Pr resultCode: "daemon_socket_timeout", }), ); - }, 35_000); + }, daemonRequestTimeoutMs(request)); socket.on("connect", () => { socket.write(`${JSON.stringify(request)}\n`); @@ -218,6 +224,11 @@ async function sendDriverRequest(session: string, request: DriverRequest): Pr }); } +function daemonRequestTimeoutMs(request: DriverRequest): number { + if (request.type !== "open") return DEFAULT_DAEMON_REQUEST_TIMEOUT_MS; + return OPEN_INITIALIZATION_ALLOWANCE_MS + (request.timeoutMs ?? DEFAULT_NAVIGATION_TIMEOUT_MS); +} + function spawnDaemon(session: string, target: ConnectionTarget): void { const entrypoint = process.argv[1]; if (!entrypoint) { diff --git a/packages/cli/tests/driver-foundation.test.ts b/packages/cli/tests/driver-foundation.test.ts index 6a81b9743e..c3d548827b 100644 --- a/packages/cli/tests/driver-foundation.test.ts +++ b/packages/cli/tests/driver-foundation.test.ts @@ -668,6 +668,66 @@ describe("driver foundation", () => { } }); + 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("closes the browser when Stagehand.create fails", async () => { const closeBrowser = vi.fn().mockResolvedValue(undefined); const browser = { close: closeBrowser, context: {} }; From a48658ba2ac544cf8a57fe5c625f918fff24d836 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 6 Aug 2026 02:07:56 -0700 Subject: [PATCH 3/7] fix(cli): provision V4 remote sessions --- .changeset/calm-pandas-wait.md | 2 +- packages/cli/src/lib/driver/remote-types.ts | 2 +- packages/cli/src/lib/driver/remote.ts | 29 ++++++-------------- packages/cli/tests/remote-options.test.ts | 30 ++++++++++++++++++++- 4 files changed, 39 insertions(+), 24 deletions(-) diff --git a/.changeset/calm-pandas-wait.md b/.changeset/calm-pandas-wait.md index 1ac3c7675d..e8413b9502 100644 --- a/.changeset/calm-pandas-wait.md +++ b/.changeset/calm-pandas-wait.md @@ -2,4 +2,4 @@ "browse": patch --- -Keep CLI open requests alive while Stagehand initializes local or remote browser sessions. +Keep CLI open requests alive while Stagehand initializes correctly provisioned local or Browserbase sessions. diff --git a/packages/cli/src/lib/driver/remote-types.ts b/packages/cli/src/lib/driver/remote-types.ts index bd7a91415b..0311d57eb2 100644 --- a/packages/cli/src/lib/driver/remote-types.ts +++ b/packages/cli/src/lib/driver/remote-types.ts @@ -13,7 +13,7 @@ export interface RemoteStagehandOptions { export interface RemoteBrowserLaunch { browser: StagehandBrowser; identity: BrowserbaseIdentity; - release: () => Promise; + release?: () => Promise; } export interface RemoteDoctorResult { diff --git a/packages/cli/src/lib/driver/remote.ts b/packages/cli/src/lib/driver/remote.ts index 1c91cd049b..62e224f58a 100644 --- a/packages/cli/src/lib/driver/remote.ts +++ b/packages/cli/src/lib/driver/remote.ts @@ -91,28 +91,15 @@ export async function launchRemoteBrowser( forwardedEnv?: ForwardedEnv, ): Promise { const { apiKey, browser: sessionOptions } = await remoteStagehandOptions(target, forwardedEnv); - const client = new Browserbase({ apiKey }); - const session = await client.sessions.create(sessionOptions); - const sessionId = session.id?.trim(); - if (!sessionId) { - throw new Error("Browserbase session creation returned an unexpected shape."); - } - - const release = async () => { - await client.sessions.update(sessionId, { status: "REQUEST_RELEASE" }); + // 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. + const browser = await browserbase.launch({ apiKey, ...sessionOptions }); + return { + browser, + // V4 browser handles do not expose their Browserbase session ID yet. + identity: {}, }; - - try { - const browser = await browserbase.connect({ apiKey, sessionId }); - return { - browser, - identity: await remoteBrowserbaseIdentity(sessionId, forwardedEnv, client), - release, - }; - } catch (error) { - await release().catch(() => undefined); - throw error; - } } export async function remoteBrowserbaseIdentity( diff --git a/packages/cli/tests/remote-options.test.ts b/packages/cli/tests/remote-options.test.ts index 15a9733ec3..3495bc30af 100644 --- a/packages/cli/tests/remote-options.test.ts +++ b/packages/cli/tests/remote-options.test.ts @@ -1,6 +1,15 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const stagehandMocks = vi.hoisted(() => ({ + launch: vi.fn(), +})); + +vi.mock("@browserbasehq/stagehand", () => ({ + browserbase: { launch: stagehandMocks.launch }, +})); import { + launchRemoteBrowser, remoteBrowserbaseIdentity, remoteStagehandOptions, resolveExplicitRemoteTarget, @@ -13,6 +22,7 @@ describe("remote.ts (Browserbase capability)", () => { const previousApiKey = process.env.BROWSERBASE_API_KEY; beforeEach(() => { + stagehandMocks.launch.mockReset(); process.env.BROWSERBASE_API_KEY = "test-key"; }); @@ -76,6 +86,24 @@ describe("remote.ts (Browserbase capability)", () => { await expect(remoteStagehandOptions({ kind: "remote" })).rejects.toThrow(/BROWSERBASE_API_KEY/); }); + it("launches through the V4 Browserbase factory so the extension is provisioned", async () => { + const browser = { close: vi.fn() }; + stagehandMocks.launch.mockResolvedValue(browser); + + await expect( + launchRemoteBrowser({ kind: "remote", proxies: true, verified: true }), + ).resolves.toEqual({ + browser, + identity: {}, + }); + expect(stagehandMocks.launch).toHaveBeenCalledWith({ + apiKey: "test-key", + browserSettings: { verified: true }, + proxies: true, + userMetadata: expect.objectContaining({ browse_cli: "true" }), + }); + }); + it("preserves remote session and live-view output fields", async () => { const debug = async () => ({ debuggerUrl: "https://www.browserbase.com/live/session-test", From c3803932cc1eeb00d5fc397dc09cf793c6edfc24 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 6 Aug 2026 10:30:43 -0700 Subject: [PATCH 4/7] fix(cli): complete the V4 command runtime --- packages/cli/package.json | 2 +- .../cli/src/lib/driver/commands/elements.ts | 27 +-- packages/cli/src/lib/driver/commands/mouse.ts | 42 ++-- .../cli/src/lib/driver/commands/network.ts | 5 +- .../cli/src/lib/driver/commands/runtime.ts | 6 +- packages/cli/tests/driver-commands.test.ts | 191 ++++++++++++++++++ pnpm-lock.yaml | 6 +- 7 files changed, 229 insertions(+), 50 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 5511c4fda6..e20ef2da8d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,6 +48,7 @@ }, "dependencies": { "@browserbasehq/sdk": "^2.14.0", + "@browserbasehq/stagehand": "workspace:*", "@oclif/core": "^4.11.0", "@vercel/detect-agent": "^1.2.3", "archiver": "^7.0.1", @@ -58,7 +59,6 @@ "ignore": "^7.0.5", "node-html-markdown": "^1.3.0", "semver": "^7.7.4", - "stagehand-v3": "npm:@browserbasehq/stagehand@3.7.1", "tsx": "^4.20.6", "ws": "^8.18.3", "zod": "^4.2.1" diff --git a/packages/cli/src/lib/driver/commands/elements.ts b/packages/cli/src/lib/driver/commands/elements.ts index ad3cf03ca2..1e88e97551 100644 --- a/packages/cli/src/lib/driver/commands/elements.ts +++ b/packages/cli/src/lib/driver/commands/elements.ts @@ -5,13 +5,8 @@ import type { DriverCommandHandlers } from "./types.js"; export const elementsHandlers: DriverCommandHandlers = { async click(manager, params) { 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 }; }, @@ -23,15 +18,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 }; @@ -45,7 +34,7 @@ export const elementsHandlers: DriverCommandHandlers = { }) .parse(params); const page = await manager.activePage(); - const selected = await page.deepLocator(manager.resolveSelector(selector)).selectOption(values); + const selected = await page.locator(manager.resolveSelector(selector)).selectOption(values); return { selected }; }, @@ -57,9 +46,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 }; }, @@ -72,7 +59,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/mouse.ts b/packages/cli/src/lib/driver/commands/mouse.ts index 738d23507d..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,15 +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, + await page.dragAndDrop(fromX, fromY, toX, toY, { + ...(button === undefined ? {} : { button }), + ...(delay === undefined ? {} : { delay }), + ...(steps === undefined ? {} : { steps }), }); - return returnXPath - ? { dragged: true, fromXpath, toXpath, xpath: fromXpath } - : { dragged: true }; + 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/network.ts b/packages/cli/src/lib/driver/commands/network.ts index 5887f132c9..25342f7324 100644 --- a/packages/cli/src/lib/driver/commands/network.ts +++ b/packages/cli/src/lib/driver/commands/network.ts @@ -1,9 +1,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); + async "network.on"() { + throw new Error("Network capture is not yet exposed by the Stagehand V4 client."); }, async "network.off"(manager) { diff --git a/packages/cli/src/lib/driver/commands/runtime.ts b/packages/cli/src/lib/driver/commands/runtime.ts index 02c893a9a1..3a96c450a5 100644 --- a/packages/cli/src/lib/driver/commands/runtime.ts +++ b/packages/cli/src/lib/driver/commands/runtime.ts @@ -85,10 +85,8 @@ export const runtimeHandlers: DriverCommandHandlers = { return { waited: true }; }, - async cursor(manager) { - const page = await manager.activePage(); - await page.enableCursorOverlay(); - return { cursor: "enabled" }; + async cursor() { + throw new Error("The visible cursor overlay is not yet exposed by the Stagehand V4 client."); }, }; diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index 964ccbf935..18b3fb227a 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -5,7 +5,10 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { elementsHandlers } from "../src/lib/driver/commands/elements.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"; @@ -167,6 +170,194 @@ describe("driver commands", () => { }); }); + 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>[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>[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>[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("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>[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>[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 V4 capabilities that are not exposed yet", async () => { + const manager = {} as Parameters>[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", + ); + } + + await expect(networkHandlers["network.on"]!(manager, {})).rejects.toThrow( + "Network capture is not yet exposed by the Stagehand V4 client", + ); + await expect(runtimeHandlers.cursor!(manager, {})).rejects.toThrow( + "visible cursor overlay is not yet exposed by the Stagehand V4 client", + ); + }); + it("selects a remaining tab after closing the active tab", async () => { const tabs = createFakeTabManager(["tab-1", "tab-2", "tab-3"], 1); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad1fc4c48b..aea9edde86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -414,6 +414,9 @@ importers: '@browserbasehq/sdk': specifier: ^2.14.0 version: 2.16.0 + '@browserbasehq/stagehand': + specifier: workspace:* + version: link:../sdk-ts '@oclif/core': specifier: ^4.11.0 version: 4.13.0 @@ -444,9 +447,6 @@ importers: semver: specifier: ^7.7.4 version: 7.8.5 - stagehand-v3: - specifier: npm:@browserbasehq/stagehand@3.7.1 - version: '@browserbasehq/stagehand@3.7.1(playwright-core@1.56.1)(zod@4.4.3)' tsx: specifier: ^4.20.6 version: 4.23.1 From cf8c29f5afc9dd430319717c7078dbb6308fc2c9 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 11:37:37 -0700 Subject: [PATCH 5/7] fix(cli): pass tab URL to V4 newPage --- packages/cli/src/lib/driver/commands/tabs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/driver/commands/tabs.ts b/packages/cli/src/lib/driver/commands/tabs.ts index 8233476778..1f111df961 100644 --- a/packages/cli/src/lib/driver/commands/tabs.ts +++ b/packages/cli/src/lib/driver/commands/tabs.ts @@ -11,7 +11,7 @@ export const tabHandlers: DriverCommandHandlers = { async "tab.new"(manager, params) { const { url } = z.object({ url: z.string().optional() }).parse(params); const context = await manager.browserContext(); - const page = await context.newPage(url ? { url } : {}); + const page = await context.newPage(url); await context.setActivePage(page); const pages = await context.pages(); return { From 786c0a78f6598960d097d439de33a72cff094708 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sun, 9 Aug 2026 22:25:09 +0000 Subject: [PATCH 6/7] fix(cli): encode V4 screenshot bytes --- packages/cli/src/lib/driver/commands/runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/driver/commands/runtime.ts b/packages/cli/src/lib/driver/commands/runtime.ts index 3a96c450a5..106e1d12cf 100644 --- a/packages/cli/src/lib/driver/commands/runtime.ts +++ b/packages/cli/src/lib/driver/commands/runtime.ts @@ -38,7 +38,7 @@ export const runtimeHandlers: DriverCommandHandlers = { 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) { From feeadf7496b6e1dd6d228ad73cc062095af17256 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Mon, 10 Aug 2026 07:08:37 +0000 Subject: [PATCH 7/7] fix(cli): restore Browserbase session identity on V4 --- packages/cli/src/lib/driver/remote.ts | 5 +++-- packages/cli/tests/remote-options.test.ts | 20 ++++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/driver/remote.ts b/packages/cli/src/lib/driver/remote.ts index 62e224f58a..d792e30516 100644 --- a/packages/cli/src/lib/driver/remote.ts +++ b/packages/cli/src/lib/driver/remote.ts @@ -97,8 +97,9 @@ export async function launchRemoteBrowser( const browser = await browserbase.launch({ apiKey, ...sessionOptions }); return { browser, - // V4 browser handles do not expose their Browserbase session ID yet. - identity: {}, + identity: browser.sessionId + ? await remoteBrowserbaseIdentity(browser.sessionId, forwardedEnv) + : {}, }; } diff --git a/packages/cli/tests/remote-options.test.ts b/packages/cli/tests/remote-options.test.ts index 3495bc30af..c76b0fc230 100644 --- a/packages/cli/tests/remote-options.test.ts +++ b/packages/cli/tests/remote-options.test.ts @@ -3,10 +3,18 @@ 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, @@ -23,6 +31,7 @@ describe("remote.ts (Browserbase capability)", () => { beforeEach(() => { stagehandMocks.launch.mockReset(); + browserbaseSdkMocks.debug.mockReset(); process.env.BROWSERBASE_API_KEY = "test-key"; }); @@ -87,14 +96,21 @@ describe("remote.ts (Browserbase capability)", () => { }); it("launches through the V4 Browserbase factory so the extension is provisioned", async () => { - const browser = { close: vi.fn() }; + 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: {}, + 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",