From e60b149c7a3ad8358799270031a1226a11ee11a6 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Thu, 24 Jul 2025 23:01:19 -0700 Subject: [PATCH 01/13] gitignore .log files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 35193f354..aed90f1e9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ eval-summary.json package-lock.json evals/deterministic/tests/BrowserContext/tmp-test.har lib/version.ts +*.log From 0ef8a472e37969e841c78f0893ebcb1e3fdf7e3c Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Thu, 24 Jul 2025 23:03:00 -0700 Subject: [PATCH 02/13] update lockfile --- pnpm-lock.yaml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b81dabcd6..035dfa4dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,9 @@ importers: specifier: workspace:* version: link:.. devDependencies: + jszip: + specifier: ^3.10.1 + version: 3.10.1 tsx: specifier: ^4.10.5 version: 4.19.4 @@ -3153,6 +3156,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@9.0.21: resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==} @@ -3464,6 +3470,9 @@ packages: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwa@2.0.0: resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} @@ -3504,6 +3513,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -4109,6 +4121,9 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4560,6 +4575,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -8825,6 +8843,8 @@ snapshots: ignore@5.3.2: {} + immediate@3.0.6: {} + immer@9.0.21: {} import-fresh@3.3.1: @@ -9111,6 +9131,13 @@ snapshots: jsonpointer@5.0.1: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + jwa@2.0.0: dependencies: buffer-equal-constant-time: 1.0.1 @@ -9157,6 +9184,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lilconfig@3.1.3: {} linear-sum-assignment@1.0.7: @@ -10068,6 +10099,8 @@ snapshots: dependencies: quansync: 0.2.10 + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -10694,6 +10727,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} sharp@0.33.5: From 5fc4990b13fb2a3510e71c573319277037367f9b Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Thu, 24 Jul 2025 23:03:15 -0700 Subject: [PATCH 03/13] fix formatting --- .../tests/page/livePageProxy.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/evals/deterministic/tests/page/livePageProxy.test.ts b/evals/deterministic/tests/page/livePageProxy.test.ts index 2a1bd98ec..b8408d42e 100644 --- a/evals/deterministic/tests/page/livePageProxy.test.ts +++ b/evals/deterministic/tests/page/livePageProxy.test.ts @@ -8,13 +8,16 @@ test.describe("StagehandPage - live page proxy", () => { await stagehand.init(); const page = stagehand.page; - await page.goto("https://browserbase.github.io/stagehand-eval-sites/sites/five-tab/"); + await page.goto( + "https://browserbase.github.io/stagehand-eval-sites/sites/five-tab/", + ); await page.locator("body > button").click(); - await new Promise(resolve => setTimeout(resolve, 1000)); - await page.waitForURL('**/page2.html', {waitUntil: "commit"}); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await page.waitForURL("**/page2.html", { waitUntil: "commit" }); // await new Promise(resolve => setTimeout(resolve, 1000)); const currentURL = page.url(); - const expectedURL = "https://browserbase.github.io/stagehand-eval-sites/sites/five-tab/page2.html"; + const expectedURL = + "https://browserbase.github.io/stagehand-eval-sites/sites/five-tab/page2.html"; expect(currentURL).toBe(expectedURL); @@ -26,9 +29,11 @@ test.describe("StagehandPage - live page proxy", () => { await stagehand.init(); const page = stagehand.page; - await page.goto("https://browserbase.github.io/stagehand-eval-sites/sites/five-tab/"); + await page.goto( + "https://browserbase.github.io/stagehand-eval-sites/sites/five-tab/", + ); await page.locator("body > button").click(); - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); const expectedNumPages = 2; const actualNumPages = stagehand.context.pages().length; From bc77b88bc602a7ca109b7e389251c8530b35da46 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Thu, 24 Jul 2025 23:04:03 -0700 Subject: [PATCH 04/13] add cdp debug logger --- lib/debug.ts | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 lib/debug.ts diff --git a/lib/debug.ts b/lib/debug.ts new file mode 100644 index 000000000..5f8b69f72 --- /dev/null +++ b/lib/debug.ts @@ -0,0 +1,84 @@ +/** + * Debug utility for Stagehand that follows Playwright's debug pattern + * Logs to stderr when DEBUG environment variable contains the namespace + */ + +const DEBUG = process.env.DEBUG || ""; +const debugNamespaces = DEBUG.split(",").map((ns) => ns.trim()); + +export function createDebugLogger(namespace: string) { + const enabled = debugNamespaces.some((ns) => { + if (ns.endsWith("*")) { + return namespace.startsWith(ns.slice(0, -1)); + } + return ns === namespace; + }); + + return { + enabled, + log: (...args: unknown[]) => { + if (!enabled) return; + + const timestamp = new Date().toISOString(); + const prefix = `${timestamp} ${namespace} `; + + // Format the message similar to Playwright's debug output + const message = args + .map((arg) => { + if (typeof arg === "object") { + try { + return JSON.stringify(arg, null, 2); + } catch { + return String(arg); + } + } + return String(arg); + }) + .join(" "); + + // Write to stderr with timestamp and namespace prefix + process.stderr.write(`${prefix}${message}\n`); + }, + }; +} + +// Pre-configured logger for CDP protocol messages +export const shProtocolDebug = createDebugLogger("sh:protocol"); + +// Helper to wrap CDP session.send calls with logging +export async function sendCDPWithLogging( + session: { send: (method: string, params?: unknown) => Promise }, + method: string, + params: Record = {}, +): Promise { + const requestId = Math.floor(Math.random() * 10000) + 4000; + let sessionId: string | undefined; + try { + // @ts-expect-error - accessing private property + sessionId = session._sessionId || session.id; + } catch { + sessionId = undefined; + } + + const sendLog: Record = { id: requestId, method }; + if (Object.keys(params).length > 0) { + sendLog.params = params; + } + if (sessionId) { + sendLog.sessionId = sessionId; + } + shProtocolDebug.log(`SEND ► ${JSON.stringify(sendLog)}`); + + const result = (await session.send(method, params)) as T; + + const recvLog: Record = { id: requestId }; + if (result !== undefined) { + recvLog.result = result; + } + if (sessionId) { + recvLog.sessionId = sessionId; + } + shProtocolDebug.log(`◀ RECV ${JSON.stringify(recvLog)}`); + + return result; +} From 010305e9900e480c56fc44941c4ab2cbb26c1927 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Thu, 24 Jul 2025 23:45:51 -0700 Subject: [PATCH 05/13] implement cdp debug logger in stagehand uses placeholder session and message IDs and leads to duplicate entries. TODO stderr watching --- lib/StagehandPage.ts | 102 ++++++++++++++++++++++++++++++++++++++-- lib/a11y/utils.ts | 109 +++++++++++++++++++++++++++++++++++++++++-- lib/index.ts | 43 +++++++++++++++-- 3 files changed, 241 insertions(+), 13 deletions(-) diff --git a/lib/StagehandPage.ts b/lib/StagehandPage.ts index 353631963..71132116b 100644 --- a/lib/StagehandPage.ts +++ b/lib/StagehandPage.ts @@ -33,6 +33,7 @@ import { import { StagehandAPIError } from "@/types/stagehandApiErrors"; import { scriptContent } from "@/lib/dom/build/scriptContent"; import type { Protocol } from "devtools-protocol"; +import { shProtocolDebug } from "./debug"; export class StagehandPage { private stagehand: Stagehand; @@ -55,6 +56,32 @@ export class StagehandPage { private fidOrdinals: Map = new Map([ [undefined, 0], ]); + private cdpRequestId: number = 1000; // Start at 1000 to avoid conflicts with playwright + private sessionIds = new WeakMap(); + + private getNextRequestId(): number { + return this.cdpRequestId++; + } + + private getSessionId(session: CDPSession): string | undefined { + // Try to get cached session ID + let sessionId = this.sessionIds.get(session); + if (!sessionId) { + // Try to extract from session object (this is implementation-specific) + try { + // @ts-expect-error - accessing private property + sessionId = session._sessionId || session.id; + if (sessionId) { + this.sessionIds.set(session, sessionId); + } + } catch { + // Fallback - generate a placeholder + sessionId = `SH_SESSION_${Math.random().toString(36).substring(7)}`; + this.sessionIds.set(session, sessionId); + } + } + return sessionId; + } constructor( page: PlaywrightPage, @@ -482,9 +509,31 @@ ${scriptContent} \ const hasDoc = !!(await this.page.title().catch(() => false)); if (!hasDoc) await this.page.waitForLoadState("domcontentloaded"); + const sessionId = this.getSessionId(client); + + // Network.enable + const networkEnableId = this.getNextRequestId(); + shProtocolDebug.log( + `SEND ► ${JSON.stringify({ id: networkEnableId, method: "Network.enable", sessionId })}`, + ); await client.send("Network.enable"); + shProtocolDebug.log( + `◀ RECV ${JSON.stringify({ id: networkEnableId, result: {}, sessionId })}`, + ); + + // Page.enable + const pageEnableId = this.getNextRequestId(); + shProtocolDebug.log( + `SEND ► ${JSON.stringify({ id: pageEnableId, method: "Page.enable", sessionId })}`, + ); await client.send("Page.enable"); - await client.send("Target.setAutoAttach", { + shProtocolDebug.log( + `◀ RECV ${JSON.stringify({ id: pageEnableId, result: {}, sessionId })}`, + ); + + // Target.setAutoAttach + const autoAttachId = this.getNextRequestId(); + const autoAttachParams = { autoAttach: true, waitForDebuggerOnStart: false, flatten: true, @@ -492,7 +541,14 @@ ${scriptContent} \ { type: "worker", exclude: true }, { type: "shared_worker", exclude: true }, ], - }); + }; + shProtocolDebug.log( + `SEND ► ${JSON.stringify({ id: autoAttachId, method: "Target.setAutoAttach", params: autoAttachParams, sessionId })}`, + ); + await client.send("Target.setAutoAttach", autoAttachParams); + shProtocolDebug.log( + `◀ RECV ${JSON.stringify({ id: autoAttachId, result: {}, sessionId })}`, + ); return new Promise((resolve) => { const inflight = new Set(); @@ -964,10 +1020,19 @@ ${scriptContent} \ target: PlaywrightPage | Frame = this.page, ): Promise { const cached = this.cdpClients.get(target); - if (cached) return cached; + if (cached) { + shProtocolDebug.log( + `◀ getCDPClient (cached session for ${target === this.page ? "page" : "frame"})`, + ); + return cached; + } try { + shProtocolDebug.log( + `◀ newCDPSession (creating session for ${target === this.page ? "page" : "frame"})`, + ); const session = await this.context.newCDPSession(target); + shProtocolDebug.log(`▶ newCDPSession (session created)`); this.cdpClients.set(target, session); return session; } catch (err) { @@ -975,6 +1040,9 @@ ${scriptContent} \ const msg = (err as Error).message ?? ""; if (msg.includes("does not have a separate CDP session")) { // Re-use / create the top-level session instead + shProtocolDebug.log( + `◀ getCDPClient (fallback to root session for same-process iframe)`, + ); const rootSession = await this.getCDPClient(this.page); // cache the alias so we don't try again for this frame this.cdpClients.set(target, rootSession); @@ -999,11 +1067,35 @@ ${scriptContent} \ target?: PlaywrightPage | Frame, ): Promise { const client = await this.getCDPClient(target ?? this.page); + const sessionId = this.getSessionId(client); + const requestId = this.getNextRequestId(); - return client.send( + // Log CDP protocol call from Stagehand + const sendLog: Record = { id: requestId, method }; + if (Object.keys(params).length > 0) { + sendLog.params = params; + } + if (sessionId) { + sendLog.sessionId = sessionId; + } + shProtocolDebug.log(`SEND ► ${JSON.stringify(sendLog)}`); + + const result = (await client.send( method as Parameters[0], params as Parameters[1], - ) as Promise; + )) as T; + + // Log CDP protocol response + const recvLog: Record = { id: requestId }; + if (result !== undefined) { + recvLog.result = result; + } + if (sessionId) { + recvLog.sessionId = sessionId; + } + shProtocolDebug.log(`◀ RECV ${JSON.stringify(recvLog)}`); + + return result; } /** Enable a CDP domain (e.g. `"Network"` or `"DOM"`) on the chosen target. */ diff --git a/lib/a11y/utils.ts b/lib/a11y/utils.ts index b93d99720..be6499ac5 100644 --- a/lib/a11y/utils.ts +++ b/lib/a11y/utils.ts @@ -14,6 +14,7 @@ import { } from "../../types/context"; import { StagehandPage } from "../StagehandPage"; import { LogLine } from "../../types/log"; +import { shProtocolDebug } from "../debug"; import { ContentFrameNotFoundError, StagehandDomProcessError, @@ -29,6 +30,22 @@ const PUA_END = 0xf8ff; const NBSP_CHARS = new Set([0x00a0, 0x202f, 0x2007, 0xfeff]); +// Helper for CDP request IDs in a11y utils +let a11yRequestId = 3000; +function getNextA11yRequestId(): number { + return a11yRequestId++; +} + +// Helper to get session ID from CDPSession +function getSessionIdFromCDP(session: CDPSession): string | undefined { + try { + // @ts-expect-error - accessing private property + return session._sessionId || session.id; + } catch { + return undefined; + } +} + /** * Clean a string by removing private-use unicode characters, normalizing whitespace, * and trimming the result. @@ -131,7 +148,9 @@ export async function buildBackendIdMaps( session = await sp.getCDPClient(); } else { try { + shProtocolDebug.log(`◀ newCDPSession (creating session for OOPIF)`); session = await sp.context.newCDPSession(targetFrame); // OOPIF + shProtocolDebug.log(`▶ newCDPSession (session created for OOPIF)`); } catch { session = await sp.getCDPClient(); // same-proc iframe } @@ -144,10 +163,34 @@ export async function buildBackendIdMaps( try { // 1. full DOM tree - const { root } = (await session.send("DOM.getDocument", { + const params = { depth: -1, pierce: true, - })) as { root: DOMNode }; + }; + const requestId = getNextA11yRequestId(); + const sessionId = getSessionIdFromCDP(session); + + const sendLog: Record = { + id: requestId, + method: "DOM.getDocument", + params, + }; + if (sessionId) { + sendLog.sessionId = sessionId; + } + shProtocolDebug.log(`SEND ► ${JSON.stringify(sendLog)}`); + + const result = (await session.send("DOM.getDocument", params)) as { + root: DOMNode; + }; + + const recvLog: Record = { id: requestId, result }; + if (sessionId) { + recvLog.sessionId = sessionId; + } + shProtocolDebug.log(`◀ RECV ${JSON.stringify(recvLog)}`); + + const { root } = result; // 2. pick start node + root frame-id let startNode: DOMNode = root; @@ -460,9 +503,30 @@ export async function getCDPFrameId( // 2. OOPIF path: open its own target try { + shProtocolDebug.log(`◀ newCDPSession (creating session for frame tree)`); const sess = await sp.context.newCDPSession(frame); // throws if detached + shProtocolDebug.log(`▶ newCDPSession (session created for frame tree)`); + + const requestId = getNextA11yRequestId(); + const sessionId = getSessionIdFromCDP(sess); + + const sendLog: Record = { + id: requestId, + method: "Page.getFrameTree", + }; + if (sessionId) { + sendLog.sessionId = sessionId; + } + shProtocolDebug.log(`SEND ► ${JSON.stringify(sendLog)}`); const ownResp = (await sess.send("Page.getFrameTree")) as unknown; + + const recvLog: Record = { id: requestId, result: ownResp }; + if (sessionId) { + recvLog.sessionId = sessionId; + } + shProtocolDebug.log(`◀ RECV ${JSON.stringify(recvLog)}`); + const { frameTree } = ownResp as { frameTree: CdpFrameTree }; return frameTree.frame.id; // root of OOPIF @@ -504,8 +568,11 @@ export async function getAccessibilityTree( // try opening a CDP session: succeeds only for OOPIFs let isOopif = true; try { + shProtocolDebug.log(`◀ newCDPSession (testing if frame is OOPIF)`); await stagehandPage.context.newCDPSession(targetFrame); + shProtocolDebug.log(`▶ newCDPSession (frame is OOPIF)`); } catch { + shProtocolDebug.log(`▶ newCDPSession (frame is not OOPIF)`); isOopif = false; } @@ -676,7 +743,13 @@ export async function getFrameRootBackendNodeId( } // Create a CDP session on the main page context + shProtocolDebug.log( + `◀ newCDPSession (creating session for iframe existence check)`, + ); const cdp = await sp.page.context().newCDPSession(sp.page); + shProtocolDebug.log( + `▶ newCDPSession (session created for iframe existence check)`, + ); // Resolve the CDP frameId for the target iframe frame const fid = await getCDPFrameId(sp, frame); if (!fid) { @@ -684,9 +757,35 @@ export async function getFrameRootBackendNodeId( } // Retrieve the DOM node that owns the frame via CDP - const { backendNodeId } = (await cdp.send("DOM.getFrameOwner", { - frameId: fid, - })) as FrameOwnerResult; + const frameOwnerParams = { frameId: fid }; + const requestId = getNextA11yRequestId(); + const sessionId = getSessionIdFromCDP(cdp); + + const sendLog: Record = { + id: requestId, + method: "DOM.getFrameOwner", + params: frameOwnerParams, + }; + if (sessionId) { + sendLog.sessionId = sessionId; + } + shProtocolDebug.log(`SEND ► ${JSON.stringify(sendLog)}`); + + const frameOwnerResult = (await cdp.send( + "DOM.getFrameOwner", + frameOwnerParams, + )) as FrameOwnerResult; + + const recvLog: Record = { + id: requestId, + result: frameOwnerResult, + }; + if (sessionId) { + recvLog.sessionId = sessionId; + } + shProtocolDebug.log(`◀ RECV ${JSON.stringify(recvLog)}`); + + const { backendNodeId } = frameOwnerResult; return backendNodeId ?? null; } diff --git a/lib/index.ts b/lib/index.ts index 60551d331..e5f7bb0d8 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -27,6 +27,7 @@ import { StagehandAPI } from "./api"; import { scriptContent } from "./dom/build/scriptContent"; import { LLMClient } from "./llm/LLMClient"; import { LLMProvider } from "./llm/LLMProvider"; +import { shProtocolDebug } from "./debug"; import { ClientOptions } from "../types/model"; import { isRunningInBun, loadApiKeyFromEnv } from "./utils"; import { ApiResponse, ErrorResponse } from "@/types/api"; @@ -821,12 +822,48 @@ export class Stagehand { content: guardedScript, }); + shProtocolDebug.log( + `◀ newCDPSession (creating session for download behavior)`, + ); const session = await this.context.newCDPSession(this.page); - await session.send("Browser.setDownloadBehavior", { - behavior: "allow", + shProtocolDebug.log(`▶ newCDPSession (session created)`); + + // Log CDP protocol call from Stagehand + const params = { + behavior: "allow" as const, downloadPath: this.downloadsPath, eventsEnabled: true, - }); + }; + + // Generate a request ID for standalone CDP calls + const requestId = Math.floor(Math.random() * 10000) + 2000; + // Try to get session ID + let cdpSessionId: string | undefined; + try { + // @ts-expect-error - accessing private property + cdpSessionId = session._sessionId || session.id; + } catch { + cdpSessionId = undefined; + } + + const sendLog: Record = { + id: requestId, + method: "Browser.setDownloadBehavior", + params, + }; + if (cdpSessionId) { + sendLog.sessionId = cdpSessionId; + } + shProtocolDebug.log(`SEND ► ${JSON.stringify(sendLog)}`); + + await session.send("Browser.setDownloadBehavior", params); + + // No response data for this method + const recvLog: Record = { id: requestId, result: {} }; + if (cdpSessionId) { + recvLog.sessionId = cdpSessionId; + } + shProtocolDebug.log(`◀ RECV ${JSON.stringify(recvLog)}`); this.browserbaseSessionID = sessionId; From ccb2abb650d443b2131e70d85297f8cc401c7c32 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Fri, 25 Jul 2025 00:08:15 -0700 Subject: [PATCH 06/13] intercept stderr to mark stagehand origin CDP events --- lib/StagehandPage.ts | 97 +++-------------------------- lib/a11y/utils.ts | 106 +++----------------------------- lib/debug.ts | 141 ++++++++++++++++++++++++++++++------------- lib/index.ts | 44 ++------------ 4 files changed, 122 insertions(+), 266 deletions(-) diff --git a/lib/StagehandPage.ts b/lib/StagehandPage.ts index 71132116b..79510ada5 100644 --- a/lib/StagehandPage.ts +++ b/lib/StagehandPage.ts @@ -33,7 +33,7 @@ import { import { StagehandAPIError } from "@/types/stagehandApiErrors"; import { scriptContent } from "@/lib/dom/build/scriptContent"; import type { Protocol } from "devtools-protocol"; -import { shProtocolDebug } from "./debug"; +import { markStagehandCDPCall } from "./debug"; export class StagehandPage { private stagehand: Stagehand; @@ -56,32 +56,6 @@ export class StagehandPage { private fidOrdinals: Map = new Map([ [undefined, 0], ]); - private cdpRequestId: number = 1000; // Start at 1000 to avoid conflicts with playwright - private sessionIds = new WeakMap(); - - private getNextRequestId(): number { - return this.cdpRequestId++; - } - - private getSessionId(session: CDPSession): string | undefined { - // Try to get cached session ID - let sessionId = this.sessionIds.get(session); - if (!sessionId) { - // Try to extract from session object (this is implementation-specific) - try { - // @ts-expect-error - accessing private property - sessionId = session._sessionId || session.id; - if (sessionId) { - this.sessionIds.set(session, sessionId); - } - } catch { - // Fallback - generate a placeholder - sessionId = `SH_SESSION_${Math.random().toString(36).substring(7)}`; - this.sessionIds.set(session, sessionId); - } - } - return sessionId; - } constructor( page: PlaywrightPage, @@ -509,31 +483,14 @@ ${scriptContent} \ const hasDoc = !!(await this.page.title().catch(() => false)); if (!hasDoc) await this.page.waitForLoadState("domcontentloaded"); - const sessionId = this.getSessionId(client); - - // Network.enable - const networkEnableId = this.getNextRequestId(); - shProtocolDebug.log( - `SEND ► ${JSON.stringify({ id: networkEnableId, method: "Network.enable", sessionId })}`, - ); + markStagehandCDPCall("Network.enable"); await client.send("Network.enable"); - shProtocolDebug.log( - `◀ RECV ${JSON.stringify({ id: networkEnableId, result: {}, sessionId })}`, - ); - - // Page.enable - const pageEnableId = this.getNextRequestId(); - shProtocolDebug.log( - `SEND ► ${JSON.stringify({ id: pageEnableId, method: "Page.enable", sessionId })}`, - ); + + markStagehandCDPCall("Page.enable"); await client.send("Page.enable"); - shProtocolDebug.log( - `◀ RECV ${JSON.stringify({ id: pageEnableId, result: {}, sessionId })}`, - ); - // Target.setAutoAttach - const autoAttachId = this.getNextRequestId(); - const autoAttachParams = { + markStagehandCDPCall("Target.setAutoAttach"); + await client.send("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true, @@ -541,14 +498,7 @@ ${scriptContent} \ { type: "worker", exclude: true }, { type: "shared_worker", exclude: true }, ], - }; - shProtocolDebug.log( - `SEND ► ${JSON.stringify({ id: autoAttachId, method: "Target.setAutoAttach", params: autoAttachParams, sessionId })}`, - ); - await client.send("Target.setAutoAttach", autoAttachParams); - shProtocolDebug.log( - `◀ RECV ${JSON.stringify({ id: autoAttachId, result: {}, sessionId })}`, - ); + }); return new Promise((resolve) => { const inflight = new Set(); @@ -1021,18 +971,11 @@ ${scriptContent} \ ): Promise { const cached = this.cdpClients.get(target); if (cached) { - shProtocolDebug.log( - `◀ getCDPClient (cached session for ${target === this.page ? "page" : "frame"})`, - ); return cached; } try { - shProtocolDebug.log( - `◀ newCDPSession (creating session for ${target === this.page ? "page" : "frame"})`, - ); const session = await this.context.newCDPSession(target); - shProtocolDebug.log(`▶ newCDPSession (session created)`); this.cdpClients.set(target, session); return session; } catch (err) { @@ -1040,9 +983,6 @@ ${scriptContent} \ const msg = (err as Error).message ?? ""; if (msg.includes("does not have a separate CDP session")) { // Re-use / create the top-level session instead - shProtocolDebug.log( - `◀ getCDPClient (fallback to root session for same-process iframe)`, - ); const rootSession = await this.getCDPClient(this.page); // cache the alias so we don't try again for this frame this.cdpClients.set(target, rootSession); @@ -1067,34 +1007,15 @@ ${scriptContent} \ target?: PlaywrightPage | Frame, ): Promise { const client = await this.getCDPClient(target ?? this.page); - const sessionId = this.getSessionId(client); - const requestId = this.getNextRequestId(); - // Log CDP protocol call from Stagehand - const sendLog: Record = { id: requestId, method }; - if (Object.keys(params).length > 0) { - sendLog.params = params; - } - if (sessionId) { - sendLog.sessionId = sessionId; - } - shProtocolDebug.log(`SEND ► ${JSON.stringify(sendLog)}`); + // Mark this as a Stagehand CDP call + markStagehandCDPCall(method); const result = (await client.send( method as Parameters[0], params as Parameters[1], )) as T; - // Log CDP protocol response - const recvLog: Record = { id: requestId }; - if (result !== undefined) { - recvLog.result = result; - } - if (sessionId) { - recvLog.sessionId = sessionId; - } - shProtocolDebug.log(`◀ RECV ${JSON.stringify(recvLog)}`); - return result; } diff --git a/lib/a11y/utils.ts b/lib/a11y/utils.ts index be6499ac5..824cd6aa1 100644 --- a/lib/a11y/utils.ts +++ b/lib/a11y/utils.ts @@ -14,7 +14,7 @@ import { } from "../../types/context"; import { StagehandPage } from "../StagehandPage"; import { LogLine } from "../../types/log"; -import { shProtocolDebug } from "../debug"; +import { markStagehandCDPCall } from "../debug"; import { ContentFrameNotFoundError, StagehandDomProcessError, @@ -30,22 +30,6 @@ const PUA_END = 0xf8ff; const NBSP_CHARS = new Set([0x00a0, 0x202f, 0x2007, 0xfeff]); -// Helper for CDP request IDs in a11y utils -let a11yRequestId = 3000; -function getNextA11yRequestId(): number { - return a11yRequestId++; -} - -// Helper to get session ID from CDPSession -function getSessionIdFromCDP(session: CDPSession): string | undefined { - try { - // @ts-expect-error - accessing private property - return session._sessionId || session.id; - } catch { - return undefined; - } -} - /** * Clean a string by removing private-use unicode characters, normalizing whitespace, * and trimming the result. @@ -148,9 +132,7 @@ export async function buildBackendIdMaps( session = await sp.getCDPClient(); } else { try { - shProtocolDebug.log(`◀ newCDPSession (creating session for OOPIF)`); session = await sp.context.newCDPSession(targetFrame); // OOPIF - shProtocolDebug.log(`▶ newCDPSession (session created for OOPIF)`); } catch { session = await sp.getCDPClient(); // same-proc iframe } @@ -163,33 +145,14 @@ export async function buildBackendIdMaps( try { // 1. full DOM tree - const params = { + markStagehandCDPCall("DOM.getDocument"); + const result = (await session.send("DOM.getDocument", { depth: -1, pierce: true, - }; - const requestId = getNextA11yRequestId(); - const sessionId = getSessionIdFromCDP(session); - - const sendLog: Record = { - id: requestId, - method: "DOM.getDocument", - params, - }; - if (sessionId) { - sendLog.sessionId = sessionId; - } - shProtocolDebug.log(`SEND ► ${JSON.stringify(sendLog)}`); - - const result = (await session.send("DOM.getDocument", params)) as { + })) as { root: DOMNode; }; - const recvLog: Record = { id: requestId, result }; - if (sessionId) { - recvLog.sessionId = sessionId; - } - shProtocolDebug.log(`◀ RECV ${JSON.stringify(recvLog)}`); - const { root } = result; // 2. pick start node + root frame-id @@ -503,30 +466,11 @@ export async function getCDPFrameId( // 2. OOPIF path: open its own target try { - shProtocolDebug.log(`◀ newCDPSession (creating session for frame tree)`); const sess = await sp.context.newCDPSession(frame); // throws if detached - shProtocolDebug.log(`▶ newCDPSession (session created for frame tree)`); - - const requestId = getNextA11yRequestId(); - const sessionId = getSessionIdFromCDP(sess); - - const sendLog: Record = { - id: requestId, - method: "Page.getFrameTree", - }; - if (sessionId) { - sendLog.sessionId = sessionId; - } - shProtocolDebug.log(`SEND ► ${JSON.stringify(sendLog)}`); + markStagehandCDPCall("Page.getFrameTree"); const ownResp = (await sess.send("Page.getFrameTree")) as unknown; - const recvLog: Record = { id: requestId, result: ownResp }; - if (sessionId) { - recvLog.sessionId = sessionId; - } - shProtocolDebug.log(`◀ RECV ${JSON.stringify(recvLog)}`); - const { frameTree } = ownResp as { frameTree: CdpFrameTree }; return frameTree.frame.id; // root of OOPIF @@ -568,11 +512,8 @@ export async function getAccessibilityTree( // try opening a CDP session: succeeds only for OOPIFs let isOopif = true; try { - shProtocolDebug.log(`◀ newCDPSession (testing if frame is OOPIF)`); await stagehandPage.context.newCDPSession(targetFrame); - shProtocolDebug.log(`▶ newCDPSession (frame is OOPIF)`); } catch { - shProtocolDebug.log(`▶ newCDPSession (frame is not OOPIF)`); isOopif = false; } @@ -743,13 +684,7 @@ export async function getFrameRootBackendNodeId( } // Create a CDP session on the main page context - shProtocolDebug.log( - `◀ newCDPSession (creating session for iframe existence check)`, - ); const cdp = await sp.page.context().newCDPSession(sp.page); - shProtocolDebug.log( - `▶ newCDPSession (session created for iframe existence check)`, - ); // Resolve the CDP frameId for the target iframe frame const fid = await getCDPFrameId(sp, frame); if (!fid) { @@ -757,33 +692,10 @@ export async function getFrameRootBackendNodeId( } // Retrieve the DOM node that owns the frame via CDP - const frameOwnerParams = { frameId: fid }; - const requestId = getNextA11yRequestId(); - const sessionId = getSessionIdFromCDP(cdp); - - const sendLog: Record = { - id: requestId, - method: "DOM.getFrameOwner", - params: frameOwnerParams, - }; - if (sessionId) { - sendLog.sessionId = sessionId; - } - shProtocolDebug.log(`SEND ► ${JSON.stringify(sendLog)}`); - - const frameOwnerResult = (await cdp.send( - "DOM.getFrameOwner", - frameOwnerParams, - )) as FrameOwnerResult; - - const recvLog: Record = { - id: requestId, - result: frameOwnerResult, - }; - if (sessionId) { - recvLog.sessionId = sessionId; - } - shProtocolDebug.log(`◀ RECV ${JSON.stringify(recvLog)}`); + markStagehandCDPCall("DOM.getFrameOwner"); + const frameOwnerResult = (await cdp.send("DOM.getFrameOwner", { + frameId: fid, + })) as FrameOwnerResult; const { backendNodeId } = frameOwnerResult; diff --git a/lib/debug.ts b/lib/debug.ts index 5f8b69f72..167f407b5 100644 --- a/lib/debug.ts +++ b/lib/debug.ts @@ -1,11 +1,106 @@ /** - * Debug utility for Stagehand that follows Playwright's debug pattern - * Logs to stderr when DEBUG environment variable contains the namespace + * Debug utility for Stagehand that intercepts Playwright's CDP protocol logs + * and marks calls that originated from Stagehand code */ const DEBUG = process.env.DEBUG || ""; const debugNamespaces = DEBUG.split(",").map((ns) => ns.trim()); +// Track pending Stagehand CDP calls by method name and timestamp +interface PendingCall { + method: string; + timestamp: number; + sessionId?: string; +} + +const pendingStagehandCalls: PendingCall[] = []; +const CALL_TIMEOUT_MS = 100; // Clear old pending calls after 100ms + +// Clean up old pending calls +function cleanupOldCalls() { + const now = Date.now(); + const index = pendingStagehandCalls.findIndex( + (call) => now - call.timestamp > CALL_TIMEOUT_MS, + ); + if (index > 0) { + pendingStagehandCalls.splice(0, index); + } +} + +// Intercept stderr to rewrite Playwright protocol logs for Stagehand-originated calls +if ( + debugNamespaces.includes("sh:protocol") && + debugNamespaces.some((ns) => ns.includes("pw:protocol")) +) { + const originalStderrWrite = process.stderr.write; + + // Track message IDs that we've identified as Stagehand calls + const stagehandMessageIds = new Set(); + + process.stderr.write = function ( + chunk: unknown, + ...args: unknown[] + ): boolean { + const str = chunk?.toString(); + + if (str && str.includes("pw:protocol")) { + // Check if this is a SEND + const sendMatch = str.match(/pw:protocol SEND ► ({.*})/); + if (sendMatch) { + try { + const message = JSON.parse(sendMatch[1]); + + // Check if this matches a pending Stagehand call + cleanupOldCalls(); + const pendingIndex = pendingStagehandCalls.findIndex( + (call) => + call.method === message.method && + (!call.sessionId || call.sessionId === message.sessionId), + ); + + if (pendingIndex >= 0) { + // This is a Stagehand call - mark it and rewrite the log + stagehandMessageIds.add(message.id); + pendingStagehandCalls.splice(pendingIndex, 1); + chunk = str.replace("pw:protocol", "sh:protocol"); + } + } catch { + // Ignore JSON parse errors + } + } + + // Check if this is a RECV for a Stagehand request + const recvMatch = str.match(/pw:protocol ◀ RECV ({.*})/); + if (recvMatch) { + try { + const message = JSON.parse(recvMatch[1]); + if (message.id && stagehandMessageIds.has(message.id)) { + // This is a response to a Stagehand call + chunk = str.replace("pw:protocol", "sh:protocol"); + stagehandMessageIds.delete(message.id); + } + } catch { + // Ignore JSON parse errors + } + } + } + + return originalStderrWrite.apply(process.stderr, [chunk, ...args]); + }; +} + +// Mark that a CDP call is about to be made from Stagehand +export function markStagehandCDPCall(method: string, sessionId?: string) { + if (debugNamespaces.includes("sh:protocol")) { + pendingStagehandCalls.push({ + method, + timestamp: Date.now(), + sessionId, + }); + } +} + +// Simple logger for non-CDP messages export function createDebugLogger(namespace: string) { const enabled = debugNamespaces.some((ns) => { if (ns.endsWith("*")) { @@ -22,13 +117,13 @@ export function createDebugLogger(namespace: string) { const timestamp = new Date().toISOString(); const prefix = `${timestamp} ${namespace} `; - // Format the message similar to Playwright's debug output const message = args .map((arg) => { if (typeof arg === "object") { try { return JSON.stringify(arg, null, 2); } catch { + // Fallback to String for circular objects return String(arg); } } @@ -36,49 +131,9 @@ export function createDebugLogger(namespace: string) { }) .join(" "); - // Write to stderr with timestamp and namespace prefix process.stderr.write(`${prefix}${message}\n`); }, }; } -// Pre-configured logger for CDP protocol messages export const shProtocolDebug = createDebugLogger("sh:protocol"); - -// Helper to wrap CDP session.send calls with logging -export async function sendCDPWithLogging( - session: { send: (method: string, params?: unknown) => Promise }, - method: string, - params: Record = {}, -): Promise { - const requestId = Math.floor(Math.random() * 10000) + 4000; - let sessionId: string | undefined; - try { - // @ts-expect-error - accessing private property - sessionId = session._sessionId || session.id; - } catch { - sessionId = undefined; - } - - const sendLog: Record = { id: requestId, method }; - if (Object.keys(params).length > 0) { - sendLog.params = params; - } - if (sessionId) { - sendLog.sessionId = sessionId; - } - shProtocolDebug.log(`SEND ► ${JSON.stringify(sendLog)}`); - - const result = (await session.send(method, params)) as T; - - const recvLog: Record = { id: requestId }; - if (result !== undefined) { - recvLog.result = result; - } - if (sessionId) { - recvLog.sessionId = sessionId; - } - shProtocolDebug.log(`◀ RECV ${JSON.stringify(recvLog)}`); - - return result; -} diff --git a/lib/index.ts b/lib/index.ts index e5f7bb0d8..5184fc3b3 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -27,7 +27,7 @@ import { StagehandAPI } from "./api"; import { scriptContent } from "./dom/build/scriptContent"; import { LLMClient } from "./llm/LLMClient"; import { LLMProvider } from "./llm/LLMProvider"; -import { shProtocolDebug } from "./debug"; +import { markStagehandCDPCall } from "./debug"; import { ClientOptions } from "../types/model"; import { isRunningInBun, loadApiKeyFromEnv } from "./utils"; import { ApiResponse, ErrorResponse } from "@/types/api"; @@ -822,48 +822,16 @@ export class Stagehand { content: guardedScript, }); - shProtocolDebug.log( - `◀ newCDPSession (creating session for download behavior)`, - ); const session = await this.context.newCDPSession(this.page); - shProtocolDebug.log(`▶ newCDPSession (session created)`); - // Log CDP protocol call from Stagehand - const params = { + // Mark this as a Stagehand CDP call + markStagehandCDPCall("Browser.setDownloadBehavior"); + + await session.send("Browser.setDownloadBehavior", { behavior: "allow" as const, downloadPath: this.downloadsPath, eventsEnabled: true, - }; - - // Generate a request ID for standalone CDP calls - const requestId = Math.floor(Math.random() * 10000) + 2000; - // Try to get session ID - let cdpSessionId: string | undefined; - try { - // @ts-expect-error - accessing private property - cdpSessionId = session._sessionId || session.id; - } catch { - cdpSessionId = undefined; - } - - const sendLog: Record = { - id: requestId, - method: "Browser.setDownloadBehavior", - params, - }; - if (cdpSessionId) { - sendLog.sessionId = cdpSessionId; - } - shProtocolDebug.log(`SEND ► ${JSON.stringify(sendLog)}`); - - await session.send("Browser.setDownloadBehavior", params); - - // No response data for this method - const recvLog: Record = { id: requestId, result: {} }; - if (cdpSessionId) { - recvLog.sessionId = cdpSessionId; - } - shProtocolDebug.log(`◀ RECV ${JSON.stringify(recvLog)}`); + }); this.browserbaseSessionID = sessionId; From 278d98e79bd2e1624cf92a8b4caf7c867ddc67f2 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Fri, 25 Jul 2025 10:47:32 -0700 Subject: [PATCH 07/13] enable pw:protocol automatically --- lib/StagehandContext.ts | 1 + lib/StagehandPage.ts | 1 + lib/debug.ts | 11 +++++++++++ lib/index.ts | 1 + 4 files changed, 14 insertions(+) diff --git a/lib/StagehandContext.ts b/lib/StagehandContext.ts index 41b6fe1fd..20e3736a2 100644 --- a/lib/StagehandContext.ts +++ b/lib/StagehandContext.ts @@ -1,3 +1,4 @@ +import "./debug"; import type { BrowserContext as PlaywrightContext, Page as PlaywrightPage, diff --git a/lib/StagehandPage.ts b/lib/StagehandPage.ts index 79510ada5..4469ecbe3 100644 --- a/lib/StagehandPage.ts +++ b/lib/StagehandPage.ts @@ -1,3 +1,4 @@ +import "./debug"; import { Browserbase } from "@browserbasehq/sdk"; import type { CDPSession, Page as PlaywrightPage, Frame } from "playwright"; import { chromium } from "playwright"; diff --git a/lib/debug.ts b/lib/debug.ts index 167f407b5..c69591749 100644 --- a/lib/debug.ts +++ b/lib/debug.ts @@ -6,6 +6,17 @@ const DEBUG = process.env.DEBUG || ""; const debugNamespaces = DEBUG.split(",").map((ns) => ns.trim()); +// If sh:protocol is enabled, automatically enable pw:protocol +// This must happen before any Playwright imports +if ( + debugNamespaces.includes("sh:protocol") && + !debugNamespaces.some((ns) => ns.includes("pw:protocol")) +) { + debugNamespaces.push("pw:protocol"); + // Update process.env.DEBUG to include pw:protocol + process.env.DEBUG = debugNamespaces.join(","); +} + // Track pending Stagehand CDP calls by method name and timestamp interface PendingCall { method: string; diff --git a/lib/index.ts b/lib/index.ts index 5184fc3b3..ccbbb21c9 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,3 +1,4 @@ +import "./debug"; import { Browserbase } from "@browserbasehq/sdk"; import { Browser, chromium } from "playwright"; import dotenv from "dotenv"; From 92e3af7fb25bd4148ced6c911b900ba205c2c4e4 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Fri, 25 Jul 2025 12:17:39 -0700 Subject: [PATCH 08/13] add a logging function patterned after playwright debug logs invoked with DEBUG=sh:api added to default example script --- examples/example.ts | 2 + examples/test-api-logger.ts | 42 ++++++++++++++++++++ lib/stagehandApiLogger.ts | 77 +++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 examples/test-api-logger.ts create mode 100644 lib/stagehandApiLogger.ts diff --git a/examples/example.ts b/examples/example.ts index 608b6efdb..93c87f9ee 100644 --- a/examples/example.ts +++ b/examples/example.ts @@ -6,6 +6,7 @@ */ import { Stagehand } from "@browserbasehq/stagehand"; import StagehandConfig from "../stagehand.config"; +import { createStagehandApiLogger } from "../lib/stagehandApiLogger"; async function example(stagehand: Stagehand) { /** @@ -18,6 +19,7 @@ async function example(stagehand: Stagehand) { (async () => { const stagehand = new Stagehand({ + logger: createStagehandApiLogger(), ...StagehandConfig, }); await stagehand.init(); diff --git a/examples/test-api-logger.ts b/examples/test-api-logger.ts new file mode 100644 index 000000000..7d029ad8f --- /dev/null +++ b/examples/test-api-logger.ts @@ -0,0 +1,42 @@ +import { Stagehand } from "../lib/index"; +import { createStagehandApiLogger } from "../lib/stagehandApiLogger"; + +async function testApiLogger() { + console.log("Starting test with custom sh:api logger...\n"); + + const stagehand = new Stagehand({ + logger: createStagehandApiLogger(), + headless: false, + }); + + try { + await stagehand.init(); + const page = stagehand.page; + + console.log("\nNavigating to example.com..."); + await page.goto("https://example.com"); + + console.log("\nExtracting page title..."); + const title = await stagehand.extract({ + instruction: "Extract the main heading of the page", + }); + console.log("Extracted title:", title); + + console.log("\nPerforming a simple action..."); + await stagehand.act({ + action: "click on the 'More information' link", + }); + + console.log("\nObserving the page..."); + const observation = await stagehand.observe(); + console.log("Observation result:", observation); + + } catch (error) { + console.error("Error during test:", error); + } finally { + await stagehand.close(); + } +} + +// Run the test +testApiLogger().catch(console.error); \ No newline at end of file diff --git a/lib/stagehandApiLogger.ts b/lib/stagehandApiLogger.ts new file mode 100644 index 000000000..60ed52a50 --- /dev/null +++ b/lib/stagehandApiLogger.ts @@ -0,0 +1,77 @@ +import { LogLine, Logger } from "../types/log"; + +/** + * Creates a custom logger for Stagehand that formats logs to match + * the debug log format used by DEBUG=pw:api,pw:browser*,sh:protocol + * and labels entries with sh:api + * + * This logger writes to stderr to match the behavior of the debug module + * + * @returns {Logger} A logger function that can be passed to Stagehand's logger option + */ +export function createStagehandApiLogger(): Logger { + return (logLine: LogLine): void => { + // Generate timestamp in ISO format to match debug log format + const timestamp = new Date().toISOString(); + + // Use sh:api as the namespace + const namespace = "sh:api"; + + // Format the message with category if provided + const categoryPrefix = logLine.category ? `[${logLine.category}] ` : ""; + const formattedMessage = `${categoryPrefix}${logLine.message}`; + + // Format auxiliary data if present + let auxiliaryInfo = ""; + if (logLine.auxiliary) { + const auxData: Record = {}; + for (const [key, { value, type }] of Object.entries(logLine.auxiliary)) { + // Convert values based on their type + switch (type) { + case "integer": + auxData[key] = parseInt(value, 10); + break; + case "float": + auxData[key] = parseFloat(value); + break; + case "boolean": + auxData[key] = value === "true"; + break; + case "object": + try { + auxData[key] = JSON.parse(value); + } catch { + auxData[key] = value; + } + break; + default: + auxData[key] = value; + } + } + // Only add auxiliary info if there's actual data + if (Object.keys(auxData).length > 0) { + auxiliaryInfo = ` ${JSON.stringify(auxData)}`; + } + } + + // Construct the final log line in debug format: timestamp namespace message + const logOutput = `${timestamp} ${namespace} ${formattedMessage}${auxiliaryInfo}\n`; + + // Write to stderr to match debug module behavior + process.stderr.write(logOutput); + }; +} + +/** + * Example usage: + * + * ```typescript + * import { Stagehand } from "@browserbasehq/stagehand"; + * import { createStagehandApiLogger } from "./stagehandApiLogger"; + * + * const stagehand = new Stagehand({ + * logger: createStagehandApiLogger(), + * // other options... + * }); + * ``` + */ \ No newline at end of file From d9842b8d238adc64cd1c6d85b2ff365b87b5f42d Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Fri, 25 Jul 2025 12:20:19 -0700 Subject: [PATCH 09/13] fix lint --- examples/test-api-logger.ts | 5 ++--- lib/stagehandApiLogger.ts | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/examples/test-api-logger.ts b/examples/test-api-logger.ts index 7d029ad8f..73864d23e 100644 --- a/examples/test-api-logger.ts +++ b/examples/test-api-logger.ts @@ -3,7 +3,7 @@ import { createStagehandApiLogger } from "../lib/stagehandApiLogger"; async function testApiLogger() { console.log("Starting test with custom sh:api logger...\n"); - + const stagehand = new Stagehand({ logger: createStagehandApiLogger(), headless: false, @@ -30,7 +30,6 @@ async function testApiLogger() { console.log("\nObserving the page..."); const observation = await stagehand.observe(); console.log("Observation result:", observation); - } catch (error) { console.error("Error during test:", error); } finally { @@ -39,4 +38,4 @@ async function testApiLogger() { } // Run the test -testApiLogger().catch(console.error); \ No newline at end of file +testApiLogger().catch(console.error); diff --git a/lib/stagehandApiLogger.ts b/lib/stagehandApiLogger.ts index 60ed52a50..3ba19a662 100644 --- a/lib/stagehandApiLogger.ts +++ b/lib/stagehandApiLogger.ts @@ -4,27 +4,27 @@ import { LogLine, Logger } from "../types/log"; * Creates a custom logger for Stagehand that formats logs to match * the debug log format used by DEBUG=pw:api,pw:browser*,sh:protocol * and labels entries with sh:api - * + * * This logger writes to stderr to match the behavior of the debug module - * + * * @returns {Logger} A logger function that can be passed to Stagehand's logger option */ export function createStagehandApiLogger(): Logger { return (logLine: LogLine): void => { // Generate timestamp in ISO format to match debug log format const timestamp = new Date().toISOString(); - + // Use sh:api as the namespace const namespace = "sh:api"; - + // Format the message with category if provided const categoryPrefix = logLine.category ? `[${logLine.category}] ` : ""; const formattedMessage = `${categoryPrefix}${logLine.message}`; - + // Format auxiliary data if present let auxiliaryInfo = ""; if (logLine.auxiliary) { - const auxData: Record = {}; + const auxData: Record = {}; for (const [key, { value, type }] of Object.entries(logLine.auxiliary)) { // Convert values based on their type switch (type) { @@ -53,10 +53,10 @@ export function createStagehandApiLogger(): Logger { auxiliaryInfo = ` ${JSON.stringify(auxData)}`; } } - + // Construct the final log line in debug format: timestamp namespace message const logOutput = `${timestamp} ${namespace} ${formattedMessage}${auxiliaryInfo}\n`; - + // Write to stderr to match debug module behavior process.stderr.write(logOutput); }; @@ -64,14 +64,14 @@ export function createStagehandApiLogger(): Logger { /** * Example usage: - * + * * ```typescript * import { Stagehand } from "@browserbasehq/stagehand"; * import { createStagehandApiLogger } from "./stagehandApiLogger"; - * + * * const stagehand = new Stagehand({ * logger: createStagehandApiLogger(), * // other options... * }); * ``` - */ \ No newline at end of file + */ From 3cac4f7b3f213c5789b8f693a16313734930ba90 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Fri, 25 Jul 2025 12:52:54 -0700 Subject: [PATCH 10/13] fix build --- examples/test-api-logger.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/examples/test-api-logger.ts b/examples/test-api-logger.ts index 73864d23e..29562beb1 100644 --- a/examples/test-api-logger.ts +++ b/examples/test-api-logger.ts @@ -5,8 +5,11 @@ async function testApiLogger() { console.log("Starting test with custom sh:api logger...\n"); const stagehand = new Stagehand({ + env: "LOCAL", logger: createStagehandApiLogger(), - headless: false, + localBrowserLaunchOptions: { + headless: false, + }, }); try { @@ -17,18 +20,18 @@ async function testApiLogger() { await page.goto("https://example.com"); console.log("\nExtracting page title..."); - const title = await stagehand.extract({ + const title = await page.extract({ instruction: "Extract the main heading of the page", }); console.log("Extracted title:", title); console.log("\nPerforming a simple action..."); - await stagehand.act({ + await page.act({ action: "click on the 'More information' link", }); console.log("\nObserving the page..."); - const observation = await stagehand.observe(); + const observation = await page.observe(); console.log("Observation result:", observation); } catch (error) { console.error("Error during test:", error); From e4f1e10c62cf89ee486220cc39f9cbdaf9c34151 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:55:39 -0700 Subject: [PATCH 11/13] fix weird test fail --- lib/StagehandPage.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/lib/StagehandPage.ts b/lib/StagehandPage.ts index 4469ecbe3..c93e9ad94 100644 --- a/lib/StagehandPage.ts +++ b/lib/StagehandPage.ts @@ -353,13 +353,13 @@ ${scriptContent} \ // Handle goto specially if (prop === "goto") { - const rawGoto: typeof target.goto = - Object.getPrototypeOf(target).goto.bind(target); return async (url: string, options: GotoOptions) => { this.intContext.setActivePage(this); + + // Use the raw page directly for navigation const result = this.api ? await this.api.goto(url, options) - : await rawGoto(url, options); + : await target.goto(url, options); this.stagehand.addToHistory("navigate", { url, options }, result); @@ -383,7 +383,24 @@ ${scriptContent} \ }); } await target.waitForLoadState("domcontentloaded"); - await this._waitForSettledDom(); + // Skip DOM settling during initial navigation + if (this.initialized) { + try { + await this._waitForSettledDom(); + } catch (err) { + this.stagehand.log({ + category: "navigation", + message: "Failed to wait for settled DOM, continuing", + level: 2, + auxiliary: { + error: { + value: (err as Error).message, + type: "string", + }, + }, + }); + } + } } return result; }; From cb7e64f185ecd1ea565f64a873f3fb2bb8509bc5 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Tue, 29 Jul 2025 10:07:06 -0700 Subject: [PATCH 12/13] revert change to default example logger --- examples/example.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/example.ts b/examples/example.ts index 93c87f9ee..608b6efdb 100644 --- a/examples/example.ts +++ b/examples/example.ts @@ -6,7 +6,6 @@ */ import { Stagehand } from "@browserbasehq/stagehand"; import StagehandConfig from "../stagehand.config"; -import { createStagehandApiLogger } from "../lib/stagehandApiLogger"; async function example(stagehand: Stagehand) { /** @@ -19,7 +18,6 @@ async function example(stagehand: Stagehand) { (async () => { const stagehand = new Stagehand({ - logger: createStagehandApiLogger(), ...StagehandConfig, }); await stagehand.init(); From 0d95263e324e149b46c44784228dce7ab14b78b2 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Tue, 19 Aug 2025 13:21:34 -0700 Subject: [PATCH 13/13] fix merge --- lib/StagehandPage.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/StagehandPage.ts b/lib/StagehandPage.ts index 0c17324a6..33aa8b712 100644 --- a/lib/StagehandPage.ts +++ b/lib/StagehandPage.ts @@ -446,6 +446,8 @@ ${scriptContent} \ // Handle goto specially if (prop === "goto") { + const rawGoto: typeof target.goto = + Object.getPrototypeOf(target).goto.bind(target); return async (url: string, options: GotoOptions) => { const result = this.api