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/fast-buses-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": minor
---

Add cookie management APIs: `context.addCookies()`, `context.clearCookies()`, & `context.cookies()`
231 changes: 231 additions & 0 deletions packages/core/lib/v3/tests/cookies.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { test, expect } from "@playwright/test";
import { V3 } from "../v3.js";
import { v3DynamicTestConfig } from "./v3.dynamic.config.js";
import { closeV3 } from "./testUtils.js";

const BASE_URL =
"https://browserbase.github.io/stagehand-eval-sites/sites/example/";

test.describe("cookies", () => {
let v3: V3;

test.beforeEach(async () => {
v3 = new V3(v3DynamicTestConfig);
await v3.init();
});

test.afterEach(async () => {
await closeV3(v3);
});

test("addCookies sets a cookie visible to the page", async () => {
const ctx = v3.context;
const page = ctx.pages()[0];
expect(page).toBeDefined();

await page!.goto(BASE_URL);

const name = `stagehand_cookie_${Date.now()}`;
await ctx.addCookies([
{
name,
value: "1",
url: BASE_URL,
httpOnly: false,
},
]);

await page!.reload();

const cookieString = await page!.evaluate(() => document.cookie);
expect(cookieString).toContain(`${name}=1`);

const cookies = await ctx.cookies(BASE_URL);
expect(cookies.some((c) => c.name === name && c.value === "1")).toBe(true);
});

test("cookies() with no URL returns all cookies", async () => {
const ctx = v3.context;
const page = ctx.pages()[0]!;
await page.goto(BASE_URL);

const name = `stagehand_all_${Date.now()}`;
await ctx.addCookies([
{ name, value: "all", url: BASE_URL, httpOnly: false },
]);

const all = await ctx.cookies();
expect(all.some((c) => c.name === name)).toBe(true);
});

test("clearCookies() removes all cookies", async () => {
const ctx = v3.context;
const page = ctx.pages()[0]!;
await page.goto(BASE_URL);

await ctx.addCookies([
{ name: "to_clear_a", value: "1", url: BASE_URL, httpOnly: false },
{ name: "to_clear_b", value: "2", url: BASE_URL, httpOnly: false },
]);

// Verify cookies were set
let cookies = await ctx.cookies(BASE_URL);
expect(cookies.some((c) => c.name === "to_clear_a")).toBe(true);
expect(cookies.some((c) => c.name === "to_clear_b")).toBe(true);

await ctx.clearCookies();

cookies = await ctx.cookies(BASE_URL);
expect(cookies.some((c) => c.name === "to_clear_a")).toBe(false);
expect(cookies.some((c) => c.name === "to_clear_b")).toBe(false);
});

test("clearCookies() with name filter removes only matching cookies", async () => {
const ctx = v3.context;
const page = ctx.pages()[0]!;
await page.goto(BASE_URL);

await ctx.addCookies([
{ name: "keep_me", value: "1", url: BASE_URL, httpOnly: false },
{ name: "remove_me", value: "2", url: BASE_URL, httpOnly: false },
]);

await ctx.clearCookies({ name: "remove_me" });

const cookies = await ctx.cookies(BASE_URL);
expect(cookies.some((c) => c.name === "keep_me")).toBe(true);
expect(cookies.some((c) => c.name === "remove_me")).toBe(false);
});

test("clearCookies() with regex filter removes matching cookies", async () => {
const ctx = v3.context;
const page = ctx.pages()[0]!;
await page.goto(BASE_URL);

await ctx.addCookies([
{ name: "_ga_ABC", value: "1", url: BASE_URL, httpOnly: false },
{ name: "_ga_DEF", value: "2", url: BASE_URL, httpOnly: false },
{ name: "session", value: "3", url: BASE_URL, httpOnly: false },
]);

await ctx.clearCookies({ name: /^_ga/ });

const cookies = await ctx.cookies(BASE_URL);
expect(cookies.some((c) => c.name === "session")).toBe(true);
expect(cookies.some((c) => c.name === "_ga_ABC")).toBe(false);
expect(cookies.some((c) => c.name === "_ga_DEF")).toBe(false);
});

test("cookies are visible from a second page on the same domain", async () => {
const ctx = v3.context;
const page1 = ctx.pages()[0]!;
await page1.goto(BASE_URL);

const name = `stagehand_multi_${Date.now()}`;
await ctx.addCookies([
{ name, value: "shared", url: BASE_URL, httpOnly: false },
]);

const page2 = await ctx.newPage();
await page2.goto(BASE_URL);

const cookieString = await page2.evaluate(() => document.cookie);
expect(cookieString).toContain(`${name}=shared`);
});

test("cookies persist across navigation to a different path", async () => {
const ctx = v3.context;
const page = ctx.pages()[0]!;
await page.goto(BASE_URL);

const name = `stagehand_nav_${Date.now()}`;
await ctx.addCookies([
{
name,
value: "persisted",
domain: "browserbase.github.io",
path: "/",
httpOnly: false,
},
]);

// Navigate to a different path on the same domain
await page.goto("https://browserbase.github.io/stagehand-eval-sites/");

const cookieString = await page.evaluate(() => document.cookie);
expect(cookieString).toContain(`${name}=persisted`);
});

test("httpOnly cookie is hidden from document.cookie but returned by cookies()", async () => {
const ctx = v3.context;
const page = ctx.pages()[0]!;
await page.goto(BASE_URL);

const name = `stagehand_http_${Date.now()}`;
await ctx.addCookies([
{ name, value: "secret", url: BASE_URL, httpOnly: true },
]);

await page.reload();

// document.cookie must NOT include httpOnly cookies
const cookieString = await page.evaluate(() => document.cookie);
expect(cookieString).not.toContain(name);

// But the context API should still return it
const cookies = await ctx.cookies(BASE_URL);
const match = cookies.find((c) => c.name === name);
expect(match).toBeDefined();
expect(match!.value).toBe("secret");
expect(match!.httpOnly).toBe(true);
});

test("cookies() returns correct shape for a fully-specified cookie", async () => {
const ctx = v3.context;
const page = ctx.pages()[0]!;
await page.goto(BASE_URL);

const name = `stagehand_shape_${Date.now()}`;
const expires = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
await ctx.addCookies([
{
name,
value: "full",
domain: "browserbase.github.io",
path: "/",
expires,
httpOnly: true,
secure: true,
sameSite: "Lax",
},
]);

const cookies = await ctx.cookies(BASE_URL);
const match = cookies.find((c) => c.name === name);
expect(match).toBeDefined();

// Validate every field on the returned Cookie object
expect(match!.value).toBe("full");
expect(match!.domain).toMatch(/browserbase\.github\.io/);
expect(match!.path).toBe("/");
expect(match!.expires).toBeGreaterThan(0);
expect(match!.httpOnly).toBe(true);
expect(match!.secure).toBe(true);
expect(match!.sameSite).toBe("Lax");

// Ensure no extra fields leak through from CDP
const keys = Object.keys(match!);
expect(keys.sort()).toEqual(
[
"name",
"value",
"domain",
"path",
"expires",
"httpOnly",
"secure",
"sameSite",
].sort(),
);
});
});
34 changes: 34 additions & 0 deletions packages/core/lib/v3/types/public/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** A cookie as returned by the browser. */
export interface Cookie {
name: string;
value: string;
domain: string;
path: string;
/** Unix time in seconds. -1 means session cookie. */
expires: number;
httpOnly: boolean;
secure: boolean;
sameSite: "Strict" | "Lax" | "None";
}

/** Parameters for setting a cookie. Provide `url` OR `domain`+`path`, not both. */
export interface CookieParam {
name: string;
value: string;
/** Convenience: if provided, domain/path/secure are derived from this URL. */
url?: string;
domain?: string;
path?: string;
/** Unix timestamp in seconds. -1 or omitted = session cookie. */
expires?: number;
httpOnly?: boolean;
secure?: boolean;
sameSite?: "Strict" | "Lax" | "None";
}

/** Filter options for clearing cookies selectively. */
export interface ClearCookieOptions {
name?: string | RegExp;
domain?: string | RegExp;
path?: string | RegExp;
}
1 change: 1 addition & 0 deletions packages/core/lib/v3/types/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export * from "./model.js";
export * from "./options.js";
export * from "./page.js";
export * from "./sdkErrors.js";
export * from "./context.js";
export { AISdkClient } from "../../external_clients/aisdk.js";
export { CustomOpenAIClient } from "../../external_clients/customOpenAI.js";
12 changes: 12 additions & 0 deletions packages/core/lib/v3/types/public/sdkErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,18 @@ export class StagehandInvalidArgumentError extends StagehandError {
}
}

export class CookieValidationError extends StagehandError {
constructor(message: string) {
super(message);
}
}

export class CookieSetError extends StagehandError {
constructor(message: string) {
super(message);
}
}

export class StagehandElementNotFoundError extends StagehandError {
constructor(xpaths: string[]) {
super(`Could not find an element for the given xPath(s): ${xpaths}`);
Expand Down
Loading
Loading