diff --git a/.changeset/small-jobs-drum.md b/.changeset/small-jobs-drum.md new file mode 100644 index 000000000..a16645b38 --- /dev/null +++ b/.changeset/small-jobs-drum.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": patch +--- + +fix issue where scripts added via context.addInitScripts() were not being injected into new pages that were opened via popups (eg, clicking a link that opens a new page) and/or calling context.newPage(url) diff --git a/packages/core/lib/v3/tests/context-addInitScript.spec.ts b/packages/core/lib/v3/tests/context-addInitScript.spec.ts index 8ed86f2e2..dcdcac511 100644 --- a/packages/core/lib/v3/tests/context-addInitScript.spec.ts +++ b/packages/core/lib/v3/tests/context-addInitScript.spec.ts @@ -114,6 +114,87 @@ test.describe("context.addInitScript", () => { expect(observed).toEqual(payload); }); + test("applies script to newPage(url) on initial document", async () => { + const payload = { marker: "newPageUrl" }; + + await ctx.addInitScript((arg) => { + function setPayload(): void { + const root = document.documentElement; + if (!root) return; + root.dataset.initPayload = JSON.stringify(arg); + } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", setPayload, { + once: true, + }); + } else { + setPayload(); + } + }, payload); + + const newPage = await ctx.newPage( + toDataUrl("new page"), + ); + await newPage.waitForLoadState("load"); + + const observed = await newPage.evaluate(() => { + const raw = document.documentElement.dataset.initPayload; + return raw ? JSON.parse(raw) : undefined; + }); + expect(observed).toEqual(payload); + }); + + test("applies script to pages opened via link clicks", async () => { + const payload = { marker: "linkClick" }; + + await ctx.addInitScript((arg) => { + function setPayload(): void { + const root = document.documentElement; + if (!root) return; + root.dataset.initPayload = JSON.stringify(arg); + } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", setPayload, { + once: true, + }); + } else { + setPayload(); + } + }, payload); + + const popupUrl = toDataUrl("popup"); + const openerHtml = + "" + + "" + + 'open' + + ""; + + const opener = await ctx.awaitActivePage(); + await opener.goto(toDataUrl(openerHtml), { waitUntil: "load" }); + await opener.locator("#open").click(); + + const openerId = opener.targetId(); + const deadline = Date.now() + 2000; + let popup = ctx.pages().find((p) => p.targetId() !== openerId); + while (!popup && Date.now() < deadline) { + await opener.waitForTimeout(25); + popup = ctx.pages().find((p) => p.targetId() !== openerId); + } + if (!popup) { + throw new Error("Popup page was not created"); + } + + await popup.waitForLoadState("load"); + + const observed = await popup.evaluate(() => { + const raw = document.documentElement.dataset.initPayload; + return raw ? JSON.parse(raw) : undefined; + }); + expect(observed).toEqual(payload); + }); + test("context.addInitScript installs a function callable from page.evaluate", async () => { const page = await ctx.awaitActivePage(); diff --git a/packages/core/lib/v3/understudy/cdp.ts b/packages/core/lib/v3/understudy/cdp.ts index 9ab81f665..cb4055b03 100644 --- a/packages/core/lib/v3/understudy/cdp.ts +++ b/packages/core/lib/v3/understudy/cdp.ts @@ -109,12 +109,7 @@ export class CdpConnection implements CDPSessionLike { await this.send("Target.setAutoAttach", { autoAttach: true, flatten: true, - waitForDebuggerOnStart: false, - filter: [ - { type: "worker", exclude: true }, - { type: "shared_worker", exclude: true }, - { type: "service_worker", exclude: true }, - ], + waitForDebuggerOnStart: true, }); await this.send("Target.setDiscoverTargets", { discover: true }); } diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index 3d2bec33e..b0ac91f0d 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -59,6 +59,20 @@ export class V3Context { private pendingCreatedTargetUrl = new Map(); private readonly initScripts: string[] = []; + private installInitScriptsOnSession( + session: CDPSessionLike, + ): Array> { + if (!this.initScripts.length) return []; + const promises: Array> = []; + promises.push(session.send("Page.enable")); + for (const source of this.initScripts) { + promises.push( + session.send("Page.addScriptToEvaluateOnNewDocument", { source }), + ); + } + return promises; + } + /** * Create a Context for a given CDP websocket URL and bootstrap target wiring. */ @@ -214,6 +228,7 @@ export class V3Context { arg?: Arg, ): Promise { const source = await normalizeInitScriptSource(script, arg); + if (this.initScripts.includes(source)) return; this.initScripts.push(source); const pages = this.pages(); await Promise.all(pages.map((page) => page.registerInitScript(source))); @@ -233,7 +248,16 @@ export class V3Context { return rows.map((r) => r.page); } - private async applyInitScriptsToPage(page: Page): Promise { + private async applyInitScriptsToPage( + page: Page, + opts?: { seedOnly?: boolean }, + ): Promise { + if (opts?.seedOnly) { + for (const source of this.initScripts) { + page.seedInitScript(source); + } + return; + } for (const source of this.initScripts) { await page.registerInitScript(source); } @@ -264,18 +288,31 @@ export class V3Context { * Waits until the target is attached and registered. */ public async newPage(url = "about:blank"): Promise { + const targetUrl = String(url ?? "about:blank"); const { targetId } = await this.conn.send<{ targetId: string }>( "Target.createTarget", - { url }, + // Create at about:blank so init scripts can install before first real navigation. + { url: "about:blank" }, ); - this.pendingCreatedTargetUrl.set(targetId, url); + this.pendingCreatedTargetUrl.set(targetId, "about:blank"); // Best-effort bring-to-front await this.conn.send("Target.activateTarget", { targetId }).catch(() => {}); const deadline = Date.now() + 5000; while (Date.now() < deadline) { const page = this.pagesByTarget.get(targetId); - if (page) return page; + if (page) { + // we created at about:blank; navigate only after attach so init scripts run + // on the first real document. Fire-and-forget so newPage() resolves on attach. + if (targetUrl !== "about:blank") { + // Seed requested URL into the page cache before navigation events arrive. + page.seedCurrentUrl(targetUrl); + void page + .sendCDP("Page.navigate", { url: targetUrl }) + .catch(() => {}); + } + return page; + } await new Promise((r) => setTimeout(r, 25)); } throw new TimeoutError(`newPage: target not attached (${targetId})`, 5000); @@ -346,6 +383,9 @@ export class V3Context { if (info.type === "page" && (ti?.openerId || ti?.openerFrameId)) { this._notePopupSignal(); } + // Let auto-attach handle top-level pages so waitForDebuggerOnStart can pause them. + if (isTopLevelPage(info)) return; + try { await this.conn.attachToTarget(info.targetId); } catch { @@ -385,93 +425,143 @@ export class V3Context { info: Protocol.Target.TargetInfo, sessionId: SessionId, ): Promise { + // Workers are ignored by Stagehand, but with waitForDebuggerOnStart enabled + // they still need to be resumed so we don't leave them paused. + if ( + info.type === "worker" || + info.type === "service_worker" || + info.type === "shared_worker" + ) { + const session = this.conn.getSession(sessionId); + if (session) { + await session.send("Runtime.runIfWaitingForDebugger").catch(() => {}); + } + return; + } + const session = this.conn.getSession(sessionId); if (!session) return; // Init guard if (this._sessionInit.has(sessionId)) return; this._sessionInit.add(sessionId); - await session.send("Runtime.runIfWaitingForDebugger").catch(() => {}); // Register for Runtime events before enabling it so we don't miss initial contexts. executionContexts.attachSession(session); - const piercerReady = await this.ensurePiercer(session); - if (!piercerReady) return; - - await session - .send("Page.setLifecycleEventsEnabled", { enabled: true }) - .catch(() => {}); - - // Top-level handling - if (isTopLevelPage(info)) { - const page = await Page.create( - this.conn, - session, - info.targetId, - this.apiClient, - this.localBrowserLaunchOptions, - this.env === "BROWSERBASE", - ); - this.wireSessionToOwnerPage(sessionId, page); - this.pagesByTarget.set(info.targetId, page); - this.mainFrameToTarget.set(page.mainFrameId(), info.targetId); - this.sessionOwnerPage.set(sessionId, page); - this.frameOwnerPage.set(page.mainFrameId(), page); - this.typeByTarget.set(info.targetId, "page"); - if (!this.createdAtByTarget.has(info.targetId)) { - this.createdAtByTarget.set(info.targetId, Date.now()); + // Ensure we only resume once even if multiple code paths hit finally. + let resumed = false; + const resume = async (): Promise => { + if (resumed) return; + resumed = true; + // waitForDebuggerOnStart pauses new targets; resume once we've done + // any "must happen before first document" work. + await session.send("Runtime.runIfWaitingForDebugger").catch(() => {}); + }; + + // Install any context-level init scripts as early as possible on this session. + // If this throws, we still resume the target but avoid re-installing later. + let scriptsInstalled = true; + let installPromises: Array> = []; + try { + installPromises = this.installInitScriptsOnSession(session); + } catch { + scriptsInstalled = false; + } + // Resume immediately so we never deadlock on paused targets; we then wait + // for the init-script sends to settle in the background. + await resume(); + if (installPromises.length) { + const results = await Promise.allSettled(installPromises); + if (results.some((r) => r.status === "rejected")) { + scriptsInstalled = false; } - const pendingSeedUrl = this.pendingCreatedTargetUrl.get(info.targetId); - this.pendingCreatedTargetUrl.delete(info.targetId); - page.seedCurrentUrl(pendingSeedUrl ?? info.url ?? ""); - this._pushActive(info.targetId); - this.installFrameEventBridges(sessionId, page); - await this.applyInitScriptsToPage(page); - - return; } - // Child (iframe / OOPIF) try { - const { frameTree } = - await session.send( - "Page.getFrameTree", + const piercerReady = await this.ensurePiercer(session); + if (!piercerReady) return; + + await session + .send("Page.setLifecycleEventsEnabled", { enabled: true }) + .catch(() => {}); + + // Top-level handling + if (isTopLevelPage(info)) { + const page = await Page.create( + this.conn, + session, + info.targetId, + this.apiClient, + this.localBrowserLaunchOptions, + this.env === "BROWSERBASE", ); - const childMainId = frameTree.frame.id; - - // Try to find owner Page now (it may already have the node in its tree) - let owner = this.frameOwnerPage.get(childMainId); - if (!owner) { - for (const p of this.pagesByTarget.values()) { - const tree = p.asProtocolFrameTree(p.mainFrameId()); - const has = (function find(n: Protocol.Page.FrameTree): boolean { - if (n.frame.id === childMainId) return true; - for (const c of n.childFrames ?? []) if (find(c)) return true; - return false; - })(tree); - if (has) { - owner = p; - break; - } + this.wireSessionToOwnerPage(sessionId, page); + this.pagesByTarget.set(info.targetId, page); + this.mainFrameToTarget.set(page.mainFrameId(), info.targetId); + this.sessionOwnerPage.set(sessionId, page); + this.frameOwnerPage.set(page.mainFrameId(), page); + this.typeByTarget.set(info.targetId, "page"); + if (!this.createdAtByTarget.has(info.targetId)) { + this.createdAtByTarget.set(info.targetId, Date.now()); } + const pendingSeedUrl = this.pendingCreatedTargetUrl.get(info.targetId); + this.pendingCreatedTargetUrl.delete(info.targetId); + page.seedCurrentUrl(pendingSeedUrl ?? info.url ?? ""); + this._pushActive(info.targetId); + this.installFrameEventBridges(sessionId, page); + // If we already installed scripts at the session level, only seed the + // Page's registry to avoid double-installing DOMContentLoaded handlers. + await this.applyInitScriptsToPage(page, { + seedOnly: scriptsInstalled, + }); + + return; } - if (owner) { - owner.adoptOopifSession(session, childMainId); - this.sessionOwnerPage.set(sessionId, owner); - this.installFrameEventBridges(sessionId, owner); - // Prime the execution-context registry so later lookups succeed even if - // the frame navigates before we issue a command. - void executionContexts - .waitForMainWorld(session, childMainId) - .catch(() => {}); - } else { - this.pendingOopifByMainFrame.set(childMainId, sessionId); + // Child (iframe / OOPIF) + try { + const { frameTree } = + await session.send( + "Page.getFrameTree", + ); + const childMainId = frameTree.frame.id; + + // Try to find owner Page now (it may already have the node in its tree) + let owner = this.frameOwnerPage.get(childMainId); + if (!owner) { + for (const p of this.pagesByTarget.values()) { + const tree = p.asProtocolFrameTree(p.mainFrameId()); + const has = (function find(n: Protocol.Page.FrameTree): boolean { + if (n.frame.id === childMainId) return true; + for (const c of n.childFrames ?? []) if (find(c)) return true; + return false; + })(tree); + if (has) { + owner = p; + break; + } + } + } + + if (owner) { + owner.adoptOopifSession(session, childMainId); + this.sessionOwnerPage.set(sessionId, owner); + this.installFrameEventBridges(sessionId, owner); + // Prime the execution-context registry so later lookups succeed even if + // the frame navigates before we issue a command. + void executionContexts + .waitForMainWorld(session, childMainId) + .catch(() => {}); + } else { + this.pendingOopifByMainFrame.set(childMainId, sessionId); + } + } catch { + // page.getFrameTree failed. Most likely was an ad iframe + // that opened & closed before we could attach. ignore } - } catch { - // page.getFrameTree failed. Most likely was an ad iframe - // that opened & closed before we could attach. ignore + } finally { + await resume(); } } diff --git a/packages/core/lib/v3/understudy/page.ts b/packages/core/lib/v3/understudy/page.ts index e8f636629..0dc141f3c 100644 --- a/packages/core/lib/v3/understudy/page.ts +++ b/packages/core/lib/v3/understudy/page.ts @@ -176,6 +176,12 @@ export class Page { await Promise.all(installs); } + // Seed an init script without re-installing it on the current sessions. + public seedInitScript(source: string): void { + if (this.initScripts.includes(source)) return; + this.initScripts.push(source); + } + // --- Optional visual cursor overlay management --- private cursorEnabled = false; private async ensureCursorScript(): Promise {