Skip to content

Commit cf41afd

Browse files
committed
add page.addInitScript()
1 parent 4e051b2 commit cf41afd

File tree

7 files changed

+238
-59
lines changed

7 files changed

+238
-59
lines changed

.changeset/orange-garlics-brake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/stagehand": patch
3+
---
4+
5+
add support for page.addInitScript()
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { test, expect } from "@playwright/test";
2+
import { V3 } from "../v3";
3+
import { v3TestConfig } from "./v3.config";
4+
import { V3Context } from "../understudy/context";
5+
6+
const EXAMPLE_URL = "https://example.com";
7+
8+
test.describe("page.addInitScript", () => {
9+
let v3: V3;
10+
let ctx: V3Context;
11+
12+
test.beforeEach(async () => {
13+
v3 = new V3(v3TestConfig);
14+
await v3.init();
15+
ctx = v3.context;
16+
});
17+
18+
test.afterEach(async () => {
19+
await v3?.close?.().catch(() => {});
20+
});
21+
22+
test("runs scripts on real network navigations", async () => {
23+
const page = await ctx.awaitActivePage();
24+
25+
await page.addInitScript(() => {
26+
(window as unknown as { __fromPageInit?: string }).__fromPageInit =
27+
"page-level";
28+
});
29+
30+
await page.goto(EXAMPLE_URL, { waitUntil: "domcontentloaded" });
31+
32+
const observed = await page.evaluate(() => {
33+
return (window as unknown as { __fromPageInit?: string }).__fromPageInit;
34+
});
35+
36+
expect(observed).toBe("page-level");
37+
});
38+
39+
test("scopes scripts to the page only", async () => {
40+
const first = await ctx.awaitActivePage();
41+
42+
await first.addInitScript(() => {
43+
function markScope(): void {
44+
const root = document.documentElement;
45+
if (!root) return;
46+
root.dataset.scopeWitness = "page-one";
47+
}
48+
if (document.readyState === "loading") {
49+
document.addEventListener("DOMContentLoaded", markScope, {
50+
once: true,
51+
});
52+
} else {
53+
markScope();
54+
}
55+
});
56+
57+
await first.goto(`${EXAMPLE_URL}/?page=one`, {
58+
waitUntil: "domcontentloaded",
59+
});
60+
61+
const second = await ctx.newPage();
62+
await second.goto(`${EXAMPLE_URL}/?page=two`, {
63+
waitUntil: "domcontentloaded",
64+
});
65+
66+
const firstValue = await first.evaluate(() => {
67+
return document.documentElement.dataset.scopeWitness ?? "missing";
68+
});
69+
const secondValue = await second.evaluate(() => {
70+
return document.documentElement.dataset.scopeWitness ?? "missing";
71+
});
72+
73+
expect(firstValue).toBe("page-one");
74+
expect(secondValue).toBe("missing");
75+
});
76+
77+
test("supports passing arguments to function sources", async () => {
78+
const page = await ctx.awaitActivePage();
79+
const payload = { greeting: "hi", nested: { count: 1 } };
80+
81+
await page.addInitScript((arg) => {
82+
function setPayload(): void {
83+
const root = document.documentElement;
84+
if (!root) return;
85+
root.dataset.pageInitPayload = JSON.stringify(arg);
86+
}
87+
if (document.readyState === "loading") {
88+
document.addEventListener("DOMContentLoaded", setPayload, {
89+
once: true,
90+
});
91+
} else {
92+
setPayload();
93+
}
94+
}, payload);
95+
96+
await page.goto(`${EXAMPLE_URL}/?page=payload`, {
97+
waitUntil: "domcontentloaded",
98+
});
99+
100+
const observed = await page.evaluate(() => {
101+
const raw = document.documentElement.dataset.pageInitPayload;
102+
return raw ? JSON.parse(raw) : undefined;
103+
});
104+
105+
expect(observed).toEqual(payload);
106+
});
107+
});

packages/core/lib/v3/types/private/internal.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,8 @@ export interface ZodPathSegments {
3232
*/
3333
segments: Array<string | number>;
3434
}
35+
36+
export type InitScriptSource<Arg> =
37+
| string
38+
| { path?: string; content?: string }
39+
| ((arg: Arg) => unknown);

packages/core/lib/v3/understudy/context.ts

Lines changed: 3 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
// lib/v3/understudy/context.ts
2-
import { promises as fs } from "fs";
32
import type { Protocol } from "devtools-protocol";
43
import { v3Logger } from "../logger";
54
import { CdpConnection, CDPSessionLike } from "./cdp";
@@ -8,70 +7,15 @@ import { installV3PiercerIntoSession } from "./piercer";
87
import { executionContexts } from "./executionContextRegistry";
98
import type { StagehandAPIClient } from "../api";
109
import { LocalBrowserLaunchOptions } from "../types/public";
11-
import {
12-
StagehandInvalidArgumentError,
13-
TimeoutError,
14-
PageNotFoundError,
15-
} from "../types/public/sdkErrors";
10+
import { InitScriptSource } from "../types/private";
11+
import { normalizeInitScriptSource } from "./initScripts";
12+
import { TimeoutError, PageNotFoundError } from "../types/public/sdkErrors";
1613

1714
type TargetId = string;
1815
type SessionId = string;
1916

2017
type TargetType = "page" | "iframe" | string;
2118

22-
type InitScriptSource<Arg> =
23-
| string
24-
| { path?: string; content?: string }
25-
| ((arg: Arg) => unknown);
26-
27-
async function normalizeInitScriptSource<Arg>(
28-
script: InitScriptSource<Arg>,
29-
arg?: Arg,
30-
): Promise<string> {
31-
if (typeof script === "function") {
32-
const argString = Object.is(arg, undefined)
33-
? "undefined"
34-
: JSON.stringify(arg);
35-
return `(${script.toString()})(${argString})`;
36-
}
37-
38-
if (!Object.is(arg, undefined)) {
39-
throw new StagehandInvalidArgumentError(
40-
"context.addInitScript: 'arg' is only supported when passing a function.",
41-
);
42-
}
43-
44-
if (typeof script === "string") {
45-
return script;
46-
}
47-
48-
if (!script || typeof script !== "object") {
49-
throw new StagehandInvalidArgumentError(
50-
"context.addInitScript: provide a string, function, or an object with path/content.",
51-
);
52-
}
53-
54-
if (typeof script.content === "string") {
55-
return script.content;
56-
}
57-
58-
if (typeof script.path === "string" && script.path.trim()) {
59-
const raw = await fs.readFile(script.path, "utf8");
60-
return appendSourceURL(raw, script.path);
61-
}
62-
63-
throw new StagehandInvalidArgumentError(
64-
"context.addInitScript: provide a string, function, or an object with path/content.",
65-
);
66-
}
67-
68-
// Chrome surfaces injected scripts using a //# sourceURL tag; mirroring Playwright keeps
69-
// stack traces and console errors pointing back to the preload file when path is used.
70-
function appendSourceURL(source: string, filePath: string): string {
71-
const sanitized = filePath.replace(/\n/g, "");
72-
return `${source}\n//# sourceURL=${sanitized}`;
73-
}
74-
7519
function isTopLevelPage(info: Protocol.Target.TargetInfo): boolean {
7620
const ti = info as unknown as { subtype?: string };
7721
return info.type === "page" && ti.subtype !== "iframe";
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { promises as fs } from "fs";
2+
import { InitScriptSource } from "../types/private";
3+
import { StagehandInvalidArgumentError } from "../types/public/sdkErrors";
4+
5+
const DEFAULT_CALLER = "context.addInitScript";
6+
7+
function appendSourceURL(source: string, filePath: string): string {
8+
const sanitized = filePath.replace(/\n/g, "");
9+
return `${source}\n//# sourceURL=${sanitized}`;
10+
}
11+
12+
export async function normalizeInitScriptSource<Arg>(
13+
script: InitScriptSource<Arg>,
14+
arg?: Arg,
15+
caller: string = DEFAULT_CALLER,
16+
): Promise<string> {
17+
if (typeof script === "function") {
18+
const argString = Object.is(arg, undefined)
19+
? "undefined"
20+
: JSON.stringify(arg);
21+
return `(${script.toString()})(${argString})`;
22+
}
23+
24+
if (!Object.is(arg, undefined)) {
25+
throw new StagehandInvalidArgumentError(
26+
`${caller}: 'arg' is only supported when passing a function.`,
27+
);
28+
}
29+
30+
if (typeof script === "string") {
31+
return script;
32+
}
33+
34+
if (!script || typeof script !== "object") {
35+
throw new StagehandInvalidArgumentError(
36+
`${caller}: provide a string, function, or an object with path/content.`,
37+
);
38+
}
39+
40+
if (typeof script.content === "string") {
41+
return script.content;
42+
}
43+
44+
if (typeof script.path === "string" && script.path.trim()) {
45+
const raw = await fs.readFile(script.path, "utf8");
46+
return appendSourceURL(raw, script.path);
47+
}
48+
49+
throw new StagehandInvalidArgumentError(
50+
`${caller}: provide a string, function, or an object with path/content.`,
51+
);
52+
}

packages/core/lib/v3/understudy/page.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
StagehandInvalidArgumentError,
2323
StagehandEvalError,
2424
} from "../types/public/sdkErrors";
25+
import { normalizeInitScriptSource } from "./initScripts";
2526
import type {
2627
ScreenshotAnimationsOption,
2728
ScreenshotCaretOption,
@@ -41,6 +42,7 @@ import {
4142
withScreenshotTimeout,
4243
type ScreenshotCleanup,
4344
} from "./screenshotUtils";
45+
import { InitScriptSource } from "../types/private";
4446
/**
4547
* Page
4648
*
@@ -257,6 +259,18 @@ export class Page {
257259
}
258260
}
259261

262+
public async addInitScript<Arg>(
263+
script: InitScriptSource<Arg>,
264+
arg?: Arg,
265+
): Promise<void> {
266+
const source = await normalizeInitScriptSource(
267+
script,
268+
arg,
269+
"page.addInitScript",
270+
);
271+
await this.registerInitScript(source);
272+
}
273+
260274
/**
261275
* Factory: create Page and seed registry with the shallow tree from Page.getFrameTree.
262276
* Assumes Page domain is already enabled on the session passed in.

packages/docs/v3/references/page.mdx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,58 @@ await page.evaluate<R, Arg>(
249249

250250
**Returns:** The result of the evaluation (must be JSON-serializable).
251251

252+
## Initialization Scripts
253+
254+
### addInitScript()
255+
256+
Inject JavaScript that runs before any of the page's scripts on every navigation.
257+
258+
```typescript
259+
await page.addInitScript<Arg>(
260+
script: string | { path?: string; content?: string } | ((arg: Arg) => unknown),
261+
arg?: Arg,
262+
): Promise<void>
263+
```
264+
265+
<ParamField
266+
path="script"
267+
type="string | { path?: string; content?: string } | (arg: Arg) => unknown"
268+
required
269+
>
270+
Provide the script to inject. Pass raw source, reference a preload file on disk,
271+
or supply a function that Stagehand serializes before sending to the browser.
272+
</ParamField>
273+
274+
<ParamField path="arg" type="Arg" optional>
275+
Extra data that is JSON-serialized and passed to your function. Only supported
276+
when `script` is a function.
277+
</ParamField>
278+
279+
This method:
280+
- Runs at document start for the current page (including adopted iframe sessions) on every navigation
281+
- Reinstalls the script for all future navigations of this page without affecting other pages
282+
- Mirrors Playwright's `page.addInitScript()` ordering semantics; use [`context.addInitScript()`](/v3/references/context#addinitscript) to target every page in the context
283+
284+
```typescript
285+
import { Stagehand } from "@browserbasehq/stagehand";
286+
287+
const stagehand = new Stagehand({ env: "LOCAL" });
288+
await stagehand.init();
289+
const context = stagehand.context;
290+
const page = await context.awaitActivePage();
291+
292+
await page.addInitScript(() => {
293+
window.Math.random = () => 42;
294+
});
295+
296+
await page.goto("https://example.com", { waitUntil: "load" });
297+
298+
const result = await page.evaluate(() => Math.random());
299+
console.log("Math.random() returned:", result);
300+
301+
// Math.random() returned: 42
302+
```
303+
252304
## Screenshot
253305

254306
### screenshot()

0 commit comments

Comments
 (0)