Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/orange-garlics-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": patch
---

add support for page.addInitScript()
107 changes: 107 additions & 0 deletions packages/core/lib/v3/tests/page-addInitScript.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
5 changes: 5 additions & 0 deletions packages/core/lib/v3/types/private/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,8 @@ export interface ZodPathSegments {
*/
segments: Array<string | number>;
}

export type InitScriptSource<Arg> =
| string
| { path?: string; content?: string }
| ((arg: Arg) => unknown);
62 changes: 3 additions & 59 deletions packages/core/lib/v3/understudy/context.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<Arg> =
| string
| { path?: string; content?: string }
| ((arg: Arg) => unknown);

async function normalizeInitScriptSource<Arg>(
script: InitScriptSource<Arg>,
arg?: Arg,
): Promise<string> {
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";
Expand Down
52 changes: 52 additions & 0 deletions packages/core/lib/v3/understudy/initScripts.ts
Original file line number Diff line number Diff line change
@@ -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<Arg>(
script: InitScriptSource<Arg>,
arg?: Arg,
caller: string = DEFAULT_CALLER,
): Promise<string> {
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.`,
);
}
14 changes: 14 additions & 0 deletions packages/core/lib/v3/understudy/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
StagehandInvalidArgumentError,
StagehandEvalError,
} from "../types/public/sdkErrors";
import { normalizeInitScriptSource } from "./initScripts";
import type {
ScreenshotAnimationsOption,
ScreenshotCaretOption,
Expand All @@ -41,6 +42,7 @@ import {
withScreenshotTimeout,
type ScreenshotCleanup,
} from "./screenshotUtils";
import { InitScriptSource } from "../types/private";
/**
* Page
*
Expand Down Expand Up @@ -257,6 +259,18 @@ export class Page {
}
}

public async addInitScript<Arg>(
script: InitScriptSource<Arg>,
arg?: Arg,
): Promise<void> {
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.
Expand Down
52 changes: 52 additions & 0 deletions packages/docs/v3/references/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,58 @@ await page.evaluate<R, Arg>(

**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<Arg>(
script: string | { path?: string; content?: string } | ((arg: Arg) => unknown),
arg?: Arg,
): Promise<void>
```

<ParamField
path="script"
type="string | { path?: string; content?: string } | (arg: Arg) => unknown"
required
>
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.
</ParamField>

<ParamField path="arg" type="Arg" optional>
Extra data that is JSON-serialized and passed to your function. Only supported
when `script` is a function.
</ParamField>

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()
Expand Down
Loading