Skip to content
Merged
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/tender-moons-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": patch
---

add support for context.addInitScript()
116 changes: 116 additions & 0 deletions packages/core/lib/v3/tests/context-addInitScript.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { test, expect } from "@playwright/test";
import { V3 } from "../v3";
import { v3TestConfig } from "./v3.config";
import { V3Context } from "../understudy/context";

const toDataUrl = (html: string): string =>
`data:text/html,${encodeURIComponent(html)}`;

test.describe("context.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 before inline document scripts on navigation", async () => {
const page = await ctx.awaitActivePage();

await ctx.addInitScript(() => {
(window as unknown as { __fromContextInit?: string }).__fromContextInit =
"injected-value";
});

const html = `<!DOCTYPE html>
<html>
<body>
<script>
var value = (window && window.__fromContextInit) || 'missing';
document.body.dataset.initWitness = value;
</script>
</body>
</html>`;

await page.goto(toDataUrl(html), { waitUntil: "load" });

const observed = await page.evaluate(() => {
return document.body.dataset.initWitness;
});
expect(observed).toBe("injected-value");
});

test("re-applies the script on every navigation for the same page", async () => {
const page = await ctx.awaitActivePage();

await ctx.addInitScript(() => {
function markVisit() {
const root = document.documentElement;
if (!root) return;
const current = Number(window.name || "0");
const next = current + 1;
window.name = String(next);
root.dataset.visitCount = String(next);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", markVisit, {
once: true,
});
} else {
markVisit();
}
});

await page.goto(toDataUrl("<html><body>first</body></html>"), {
waitUntil: "load",
});
const first = await page.evaluate(() => {
return Number(document.documentElement.dataset.visitCount ?? "0");
});
expect(first).toBe(1);

await page.goto(toDataUrl("<html><body>second</body></html>"), {
waitUntil: "load",
});
const second = await page.evaluate(() => {
return Number(document.documentElement.dataset.visitCount ?? "0");
});
expect(second).toBe(2);
});

test("applies script (with args) to newly created pages", async () => {
const payload = { greeting: "hi", nested: { count: 2 } };

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();
await newPage.goto(toDataUrl("<html><body>child</body></html>"), {
waitUntil: "load",
});

const observed = await newPage.evaluate(() => {
const raw = document.documentElement.dataset.initPayload;
return raw ? JSON.parse(raw) : undefined;
});
expect(observed).toEqual(payload);
});
});
78 changes: 77 additions & 1 deletion packages/core/lib/v3/understudy/context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// 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 @@ -7,13 +8,70 @@ import { installV3PiercerIntoSession } from "./piercer";
import { executionContexts } from "./executionContextRegistry";
import type { StagehandAPIClient } from "../api";
import { LocalBrowserLaunchOptions } from "../types/public";
import { TimeoutError, PageNotFoundError } from "../types/public/sdkErrors";
import {
StagehandInvalidArgumentError,
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 Expand Up @@ -55,6 +113,7 @@ export class V3Context {
private typeByTarget = new Map<TargetId, TargetType>();
private _pageOrder: TargetId[] = [];
private pendingCreatedTargetUrl = new Map<TargetId, string>();
private readonly initScripts: string[] = [];

/**
* Create a Context for a given CDP websocket URL and bootstrap target wiring.
Expand Down Expand Up @@ -206,6 +265,16 @@ export class V3Context {
void this.conn.send("Target.activateTarget", { targetId }).catch(() => {});
}

public async addInitScript<Arg>(
script: InitScriptSource<Arg>,
arg?: Arg,
): Promise<void> {
const source = await normalizeInitScriptSource(script, arg);
this.initScripts.push(source);
const pages = this.pages();
await Promise.all(pages.map((page) => page.registerInitScript(source)));
}

/**
* Return top-level `Page`s (oldest → newest). OOPIF targets are not included.
*/
Expand All @@ -220,6 +289,12 @@ export class V3Context {
return rows.map((r) => r.page);
}

private async applyInitScriptsToPage(page: Page): Promise<void> {
for (const source of this.initScripts) {
await page.registerInitScript(source);
}
}

/**
* Resolve an owning `Page` by the **top-level main frame id**.
* Note: child (OOPIF) roots are intentionally not present in this mapping.
Expand Down Expand Up @@ -407,6 +482,7 @@ export class V3Context {
page.seedCurrentUrl(pendingSeedUrl ?? info.url ?? "");
this._pushActive(info.targetId);
this.installFrameEventBridges(sessionId, page);
await this.applyInitScriptsToPage(page);

return;
}
Expand Down
35 changes: 35 additions & 0 deletions packages/core/lib/v3/understudy/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export class Page {
string,
(evt: Protocol.Runtime.ConsoleAPICalledEvent) => void
>();
/** Document-start scripts installed across every session this page owns. */
private readonly initScripts: string[] = [];

private constructor(
private readonly conn: CdpConnection,
Expand Down Expand Up @@ -125,6 +127,37 @@ export class Page {
this.networkManager.trackSession(this.mainSession);
}

// Send a single init script to a specific CDP session.
private async installInitScriptOnSession(
session: CDPSessionLike,
source: string,
): Promise<void> {
await session.send("Page.addScriptToEvaluateOnNewDocument", { source });
}

// Replay every previously registered init script onto a newly adopted session.
private async applyInitScriptsToSession(
session: CDPSessionLike,
): Promise<void> {
for (const source of this.initScripts) {
await this.installInitScriptOnSession(session, source);
}
}

// Register a new init script and fan it out to all active sessions for this page.
public async registerInitScript(source: string): Promise<void> {
if (this.initScripts.includes(source)) return;
this.initScripts.push(source);

const installs: Array<Promise<void>> = [];
installs.push(this.installInitScriptOnSession(this.mainSession, source));
for (const session of this.sessions.values()) {
if (session === this.mainSession) continue;
installs.push(this.installInitScriptOnSession(session, source));
}
await Promise.all(installs);
}

// --- Optional visual cursor overlay management ---
private cursorEnabled = false;
private async ensureCursorScript(): Promise<void> {
Expand Down Expand Up @@ -371,6 +404,8 @@ export class Page {

this.networkManager.trackSession(childSession);

void this.applyInitScriptsToSession(childSession).catch(() => {});

if (this.consoleListeners.size > 0) {
this.installConsoleTap(childSession);
}
Expand Down