diff --git a/.changeset/orange-garlics-brake.md b/.changeset/orange-garlics-brake.md new file mode 100644 index 000000000..190b2c939 --- /dev/null +++ b/.changeset/orange-garlics-brake.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": patch +--- + +add support for page.addInitScript() diff --git a/packages/core/lib/v3/tests/page-addInitScript.spec.ts b/packages/core/lib/v3/tests/page-addInitScript.spec.ts new file mode 100644 index 000000000..a6e1a9691 --- /dev/null +++ b/packages/core/lib/v3/tests/page-addInitScript.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from "@playwright/test"; +import { V3 } from "../v3"; +import { v3TestConfig } from "./v3.config"; +import { V3Context } from "../understudy/context"; + +const EXAMPLE_URL = "https://example.com"; + +test.describe("page.addInitScript", () => { + let v3: V3; + let ctx: V3Context; + + test.beforeEach(async () => { + v3 = new V3(v3TestConfig); + await v3.init(); + ctx = v3.context; + }); + + test.afterEach(async () => { + await v3?.close?.().catch(() => {}); + }); + + test("runs scripts on real network navigations", async () => { + const page = await ctx.awaitActivePage(); + + await page.addInitScript(() => { + (window as unknown as { __fromPageInit?: string }).__fromPageInit = + "page-level"; + }); + + await page.goto(EXAMPLE_URL, { waitUntil: "domcontentloaded" }); + + const observed = await page.evaluate(() => { + return (window as unknown as { __fromPageInit?: string }).__fromPageInit; + }); + + expect(observed).toBe("page-level"); + }); + + test("scopes scripts to the page only", async () => { + const first = await ctx.awaitActivePage(); + + await first.addInitScript(() => { + function markScope(): void { + const root = document.documentElement; + if (!root) return; + root.dataset.scopeWitness = "page-one"; + } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", markScope, { + once: true, + }); + } else { + markScope(); + } + }); + + await first.goto(`${EXAMPLE_URL}/?page=one`, { + waitUntil: "domcontentloaded", + }); + + const second = await ctx.newPage(); + await second.goto(`${EXAMPLE_URL}/?page=two`, { + waitUntil: "domcontentloaded", + }); + + const firstValue = await first.evaluate(() => { + return document.documentElement.dataset.scopeWitness ?? "missing"; + }); + const secondValue = await second.evaluate(() => { + return document.documentElement.dataset.scopeWitness ?? "missing"; + }); + + expect(firstValue).toBe("page-one"); + expect(secondValue).toBe("missing"); + }); + + test("supports passing arguments to function sources", async () => { + const page = await ctx.awaitActivePage(); + const payload = { greeting: "hi", nested: { count: 1 } }; + + await page.addInitScript((arg) => { + function setPayload(): void { + const root = document.documentElement; + if (!root) return; + root.dataset.pageInitPayload = JSON.stringify(arg); + } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", setPayload, { + once: true, + }); + } else { + setPayload(); + } + }, payload); + + await page.goto(`${EXAMPLE_URL}/?page=payload`, { + waitUntil: "domcontentloaded", + }); + + const observed = await page.evaluate(() => { + const raw = document.documentElement.dataset.pageInitPayload; + return raw ? JSON.parse(raw) : undefined; + }); + + expect(observed).toEqual(payload); + }); +}); diff --git a/packages/core/lib/v3/types/private/internal.ts b/packages/core/lib/v3/types/private/internal.ts index 7010fd2c0..0ca54c9d6 100644 --- a/packages/core/lib/v3/types/private/internal.ts +++ b/packages/core/lib/v3/types/private/internal.ts @@ -32,3 +32,8 @@ export interface ZodPathSegments { */ segments: Array; } + +export type InitScriptSource = + | string + | { path?: string; content?: string } + | ((arg: Arg) => unknown); diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index d5d8ae171..775490746 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -1,5 +1,4 @@ // lib/v3/understudy/context.ts -import { promises as fs } from "fs"; import type { Protocol } from "devtools-protocol"; import { v3Logger } from "../logger"; import { CdpConnection, CDPSessionLike } from "./cdp"; @@ -8,70 +7,15 @@ import { installV3PiercerIntoSession } from "./piercer"; import { executionContexts } from "./executionContextRegistry"; import type { StagehandAPIClient } from "../api"; import { LocalBrowserLaunchOptions } from "../types/public"; -import { - StagehandInvalidArgumentError, - TimeoutError, - PageNotFoundError, -} from "../types/public/sdkErrors"; +import { InitScriptSource } from "../types/private"; +import { normalizeInitScriptSource } from "./initScripts"; +import { TimeoutError, PageNotFoundError } from "../types/public/sdkErrors"; type TargetId = string; type SessionId = string; type TargetType = "page" | "iframe" | string; -type InitScriptSource = - | string - | { path?: string; content?: string } - | ((arg: Arg) => unknown); - -async function normalizeInitScriptSource( - script: InitScriptSource, - arg?: Arg, -): Promise { - if (typeof script === "function") { - const argString = Object.is(arg, undefined) - ? "undefined" - : JSON.stringify(arg); - return `(${script.toString()})(${argString})`; - } - - if (!Object.is(arg, undefined)) { - throw new StagehandInvalidArgumentError( - "context.addInitScript: 'arg' is only supported when passing a function.", - ); - } - - if (typeof script === "string") { - return script; - } - - if (!script || typeof script !== "object") { - throw new StagehandInvalidArgumentError( - "context.addInitScript: provide a string, function, or an object with path/content.", - ); - } - - if (typeof script.content === "string") { - return script.content; - } - - if (typeof script.path === "string" && script.path.trim()) { - const raw = await fs.readFile(script.path, "utf8"); - return appendSourceURL(raw, script.path); - } - - throw new StagehandInvalidArgumentError( - "context.addInitScript: provide a string, function, or an object with path/content.", - ); -} - -// Chrome surfaces injected scripts using a //# sourceURL tag; mirroring Playwright keeps -// stack traces and console errors pointing back to the preload file when path is used. -function appendSourceURL(source: string, filePath: string): string { - const sanitized = filePath.replace(/\n/g, ""); - return `${source}\n//# sourceURL=${sanitized}`; -} - function isTopLevelPage(info: Protocol.Target.TargetInfo): boolean { const ti = info as unknown as { subtype?: string }; return info.type === "page" && ti.subtype !== "iframe"; diff --git a/packages/core/lib/v3/understudy/initScripts.ts b/packages/core/lib/v3/understudy/initScripts.ts new file mode 100644 index 000000000..9e318d8f8 --- /dev/null +++ b/packages/core/lib/v3/understudy/initScripts.ts @@ -0,0 +1,52 @@ +import { promises as fs } from "fs"; +import { InitScriptSource } from "../types/private"; +import { StagehandInvalidArgumentError } from "../types/public/sdkErrors"; + +const DEFAULT_CALLER = "context.addInitScript"; + +function appendSourceURL(source: string, filePath: string): string { + const sanitized = filePath.replace(/\n/g, ""); + return `${source}\n//# sourceURL=${sanitized}`; +} + +export async function normalizeInitScriptSource( + script: InitScriptSource, + arg?: Arg, + caller: string = DEFAULT_CALLER, +): Promise { + if (typeof script === "function") { + const argString = Object.is(arg, undefined) + ? "undefined" + : JSON.stringify(arg); + return `(${script.toString()})(${argString})`; + } + + if (!Object.is(arg, undefined)) { + throw new StagehandInvalidArgumentError( + `${caller}: 'arg' is only supported when passing a function.`, + ); + } + + if (typeof script === "string") { + return script; + } + + if (!script || typeof script !== "object") { + throw new StagehandInvalidArgumentError( + `${caller}: provide a string, function, or an object with path/content.`, + ); + } + + if (typeof script.content === "string") { + return script.content; + } + + if (typeof script.path === "string" && script.path.trim()) { + const raw = await fs.readFile(script.path, "utf8"); + return appendSourceURL(raw, script.path); + } + + throw new StagehandInvalidArgumentError( + `${caller}: provide a string, function, or an object with path/content.`, + ); +} diff --git a/packages/core/lib/v3/understudy/page.ts b/packages/core/lib/v3/understudy/page.ts index 81245d268..f558659a3 100644 --- a/packages/core/lib/v3/understudy/page.ts +++ b/packages/core/lib/v3/understudy/page.ts @@ -22,6 +22,7 @@ import { StagehandInvalidArgumentError, StagehandEvalError, } from "../types/public/sdkErrors"; +import { normalizeInitScriptSource } from "./initScripts"; import type { ScreenshotAnimationsOption, ScreenshotCaretOption, @@ -41,6 +42,7 @@ import { withScreenshotTimeout, type ScreenshotCleanup, } from "./screenshotUtils"; +import { InitScriptSource } from "../types/private"; /** * Page * @@ -257,6 +259,18 @@ export class Page { } } + public async addInitScript( + script: InitScriptSource, + arg?: Arg, + ): Promise { + const source = await normalizeInitScriptSource( + script, + arg, + "page.addInitScript", + ); + await this.registerInitScript(source); + } + /** * Factory: create Page and seed registry with the shallow tree from Page.getFrameTree. * Assumes Page domain is already enabled on the session passed in. diff --git a/packages/docs/v3/references/page.mdx b/packages/docs/v3/references/page.mdx index 862d50316..3b2252583 100644 --- a/packages/docs/v3/references/page.mdx +++ b/packages/docs/v3/references/page.mdx @@ -249,6 +249,58 @@ await page.evaluate( **Returns:** The result of the evaluation (must be JSON-serializable). +## Initialization Scripts + +### addInitScript() + +Inject JavaScript that runs before any of the page's scripts on every navigation. + +```typescript +await page.addInitScript( + script: string | { path?: string; content?: string } | ((arg: Arg) => unknown), + arg?: Arg, +): Promise +``` + + + Provide the script to inject. Pass raw source, reference a preload file on disk, + or supply a function that Stagehand serializes before sending to the browser. + + + + Extra data that is JSON-serialized and passed to your function. Only supported + when `script` is a function. + + +This method: +- Runs at document start for the current page (including adopted iframe sessions) on every navigation +- Reinstalls the script for all future navigations of this page without affecting other pages +- Mirrors Playwright's `page.addInitScript()` ordering semantics; use [`context.addInitScript()`](/v3/references/context#addinitscript) to target every page in the context + +```typescript +import { Stagehand } from "@browserbasehq/stagehand"; + +const stagehand = new Stagehand({ env: "LOCAL" }); +await stagehand.init(); +const context = stagehand.context; +const page = await context.awaitActivePage(); + +await page.addInitScript(() => { + window.Math.random = () => 42; +}); + +await page.goto("https://example.com", { waitUntil: "load" }); + +const result = await page.evaluate(() => Math.random()); +console.log("Math.random() returned:", result); + +// Math.random() returned: 42 +``` + ## Screenshot ### screenshot()