From 53720e4898f02ea627236ed38b2893ee96475320 Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 11 Feb 2026 15:29:05 -0800 Subject: [PATCH 01/22] Add support for setting and getting cookies --- packages/core/lib/v3/index.ts | 7 + packages/core/lib/v3/understudy/context.ts | 138 ++ packages/core/lib/v3/understudy/cookies.ts | 158 +++ packages/core/lib/v3/v3.ts | 44 + packages/core/tests/cookies.test.ts | 1335 ++++++++++++++++++++ 5 files changed, 1682 insertions(+) create mode 100644 packages/core/lib/v3/understudy/cookies.ts create mode 100644 packages/core/tests/cookies.test.ts diff --git a/packages/core/lib/v3/index.ts b/packages/core/lib/v3/index.ts index 50777ef7c..3225ec5cd 100644 --- a/packages/core/lib/v3/index.ts +++ b/packages/core/lib/v3/index.ts @@ -57,3 +57,10 @@ export type { } from "./zodCompat.js"; export type { JsonSchema, JsonSchemaProperty } from "../utils.js"; + +export type { + Cookie, + CookieParam, + ClearCookieOptions, + StorageState, +} from "./understudy/cookies"; diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index 1179f5dd6..df1ae3d44 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -12,6 +12,15 @@ import { InitScriptSource } from "../types/private/index.js"; import { normalizeInitScriptSource } from "./initScripts.js"; import { TimeoutError, PageNotFoundError } from "../types/public/sdkErrors.js"; import { getEnvTimeoutMs, withTimeout } from "../timeoutConfig.js"; +import { + Cookie, + CookieParam, + ClearCookieOptions, + StorageState, + filterCookies, + normalizeCookieParams, + cookieMatchesFilter, +} from "./cookies"; type TargetId = string; type SessionId = string; @@ -823,4 +832,133 @@ export class V3Context { if (immediate) return immediate; throw new PageNotFoundError("awaitActivePage: no page available"); } + + // --------------------------------------------------------------------------- + // Cookie management (browser-context level) + // --------------------------------------------------------------------------- + + /** + * Get all browser cookies, optionally filtered by URL(s). + * + * When `urls` is omitted or empty every cookie in the browser context is + * returned. When one or more URLs are supplied only cookies whose + * domain/path/secure attributes match are included. + */ + async cookies(urls?: string | string[]): Promise { + const urlList = !urls ? [] : typeof urls === "string" ? [urls] : urls; + + const { cookies } = await this.conn.send<{ + cookies: Protocol.Network.Cookie[]; + }>("Network.getAllCookies"); + + const mapped: Cookie[] = cookies.map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + expires: c.expires, + httpOnly: c.httpOnly, + secure: c.secure, + sameSite: (c.sameSite as Cookie["sameSite"]) ?? "Lax", + })); + + return filterCookies(mapped, urlList); + } + + /** + * Add one or more cookies to the browser context. + * + * Each cookie must specify either a `url` (from which domain/path/secure are + * derived) or an explicit `domain` + `path` pair. + * + * Unlike Playwright, we check the CDP success flag for each cookie and throw + * if the browser rejects it (Playwright silently ignores failures). + */ + async addCookies(cookies: CookieParam[]): Promise { + const normalized = normalizeCookieParams(cookies); + for (const c of normalized) { + const { success } = await this.conn.send<{ success: boolean }>( + "Network.setCookie", + { + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + expires: c.expires, + httpOnly: c.httpOnly, + secure: c.secure, + sameSite: c.sameSite, + }, + ); + if (!success) { + throw new Error( + `Failed to set cookie "${c.name}" for domain "${c.domain ?? "(unknown)"}" — ` + + `the browser rejected it. Check that the domain, path, and secure/sameSite values are valid.`, + ); + } + } + } + + /** + * Clear cookies from the browser context. + * + * - Called with no arguments: clears **all** cookies. + * - Called with filter options: only cookies matching every supplied criterion + * are removed. Filters accept exact strings or RegExp patterns. + * + * Improvement over Playwright: we delete only the matching cookies via + * `Network.deleteCookies` instead of nuking everything and re-adding the + * non-matching ones. This avoids a race condition where cookies set between + * the get-all and clear-all calls would be lost. + */ + async clearCookies(options?: ClearCookieOptions): Promise { + const current = await this.cookies(); + const hasFilter = + options?.name !== undefined || + options?.domain !== undefined || + options?.path !== undefined; + + const toDelete = hasFilter + ? current.filter((c) => cookieMatchesFilter(c, options!)) + : current; + + for (const c of toDelete) { + await this.conn.send("Network.deleteCookies", { + name: c.name, + domain: c.domain, + path: c.path, + }); + } + } + + /** + * Snapshot the browser's cookie store for later restoration. + */ + async storageState(): Promise { + return { cookies: await this.cookies() }; + } + + /** + * Restore a previously saved cookie snapshot. + * Clears all existing cookies first then applies the snapshot. + * + * Improvement over Playwright: expired cookies in the snapshot are + * automatically skipped instead of being blindly re added (where the + * browser would reject them anyway). + */ + async setStorageState(state: StorageState): Promise { + await this.clearCookies(); + if (!state.cookies?.length) return; + + const nowSeconds = Date.now() / 1000; + const valid = state.cookies.filter((c) => { + // Session cookies (expires === -1) are always valid. + // Persistent cookies are only valid if they haven't expired. + return c.expires === -1 || c.expires > nowSeconds; + }); + + if (valid.length) { + await this.addCookies(valid); + } + } } diff --git a/packages/core/lib/v3/understudy/cookies.ts b/packages/core/lib/v3/understudy/cookies.ts new file mode 100644 index 000000000..dec0fcdf8 --- /dev/null +++ b/packages/core/lib/v3/understudy/cookies.ts @@ -0,0 +1,158 @@ +// lib/v3/understudy/cookies.ts + +/** + * Cookie types and helpers for browser cookie management. + * + * Mirrors Playwright's cookie API surface, adapted for direct CDP usage + * against a single default browser context. + */ + +/** 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; +} + +/** Serialisable snapshot of the browser's cookie store. */ +export interface StorageState { + cookies: Cookie[]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Filter cookies by URL matching (domain, path, secure). + * If `urls` is empty every cookie passes. + */ +export function filterCookies(cookies: Cookie[], urls: string[]): Cookie[] { + if (!urls.length) return cookies; + const parsed = urls.map((u) => new URL(u)); + return cookies.filter((c) => { + for (const url of parsed) { + let domain = c.domain; + if (!domain.startsWith(".")) domain = "." + domain; + if (!("." + url.hostname).endsWith(domain)) continue; + if (!url.pathname.startsWith(c.path)) continue; + if (url.protocol !== "https:" && url.hostname !== "localhost" && c.secure) + continue; + return true; + } + return false; + }); +} + +/** + * Validate and normalise `CookieParam` values before sending to CDP. + * + * - Ensures every cookie has either `url` or `domain`+`path`. + * - When `url` is provided, derives `domain`, `path`, and `secure` from it. + * - Validates that `sameSite: "None"` is paired with `secure: true` + * (browsers silently reject this — we throw early with a clear message). + */ +export function normalizeCookieParams(cookies: CookieParam[]): CookieParam[] { + return cookies.map((c) => { + if (!c.url && !(c.domain && c.path)) { + throw new Error( + `Cookie "${c.name}" must have a url or a domain/path pair`, + ); + } + if (c.url && c.domain) { + throw new Error( + `Cookie "${c.name}" should have either url or domain, not both`, + ); + } + if (c.url && c.path) { + throw new Error( + `Cookie "${c.name}" should have either url or path, not both`, + ); + } + if (c.expires !== undefined && c.expires < 0 && c.expires !== -1) { + throw new Error( + `Cookie "${c.name}" has an invalid expires value; use -1 for session cookies or a positive unix timestamp`, + ); + } + + const copy = { ...c }; + if (copy.url) { + if (copy.url === "about:blank") { + throw new Error(`Blank page cannot have cookie "${c.name}"`); + } + if (copy.url.startsWith("data:")) { + throw new Error(`Data URL page cannot have cookie "${c.name}"`); + } + const url = new URL(copy.url); + copy.domain = url.hostname; + copy.path = url.pathname.substring(0, url.pathname.lastIndexOf("/") + 1); + copy.secure = url.protocol === "https:"; + delete copy.url; + } + + // Browsers silently reject SameSite=None cookies that aren't Secure. + // Catch this early with a clear error instead of a silent CDP failure. + if (copy.sameSite === "None" && copy.secure === false) { + throw new Error( + `Cookie "${c.name}" has sameSite: "None" but secure: false. ` + + `Browsers require secure: true when sameSite is "None".`, + ); + } + + return copy; + }); +} + +/** + * Returns true if a cookie matches all supplied filter criteria. + * Undefined filters are treated as "match anything". + */ +export function cookieMatchesFilter( + cookie: Cookie, + options: ClearCookieOptions, +): boolean { + const check = ( + prop: "name" | "domain" | "path", + value: string | RegExp | undefined, + ): boolean => { + if (value === undefined) return true; + if (value instanceof RegExp) { + value.lastIndex = 0; + return value.test(cookie[prop]); + } + return cookie[prop] === value; + }; + return ( + check("name", options.name) && + check("domain", options.domain) && + check("path", options.path) + ); +} diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index e0e73ce77..5721c794a 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -79,6 +79,11 @@ import { AgentStreamResult, } from "./types/public/index.js"; import { V3Context } from "./understudy/context.js"; +import type { + Cookie, + CookieParam, + ClearCookieOptions, +} from "./understudy/cookies"; import { Page } from "./understudy/page.js"; import { resolveModel } from "../modelUtils.js"; import { StagehandAPIClient } from "./api.js"; @@ -1386,6 +1391,45 @@ export class V3 { return this.ctx; } + // --------------------------------------------------------------------------- + // Cookie management + // --------------------------------------------------------------------------- + + /** + * Get all browser cookies, optionally filtered by URL(s). + * + * When `urls` is omitted every cookie in the browser context is returned. + * When one or more URLs are supplied only cookies whose domain/path/secure + * attributes match are included. + */ + async cookies(urls?: string | string[]): Promise { + if (!this.ctx) throw new StagehandNotInitializedError("cookies()"); + return this.ctx.cookies(urls); + } + + /** + * Add one or more cookies to the browser context. + * + * Each cookie must specify either a `url` (from which domain/path/secure are + * derived) or an explicit `domain` + `path` pair. + */ + async addCookies(cookies: CookieParam[]): Promise { + if (!this.ctx) throw new StagehandNotInitializedError("addCookies()"); + return this.ctx.addCookies(cookies); + } + + /** + * Clear cookies from the browser context. + * + * - Called with no arguments: clears **all** cookies. + * - Called with filter options: only cookies matching every supplied criterion + * are removed. + */ + async clearCookies(options?: ClearCookieOptions): Promise { + if (!this.ctx) throw new StagehandNotInitializedError("clearCookies()"); + return this.ctx.clearCookies(options); + } + /** Best-effort cleanup of context and launched resources. */ async close(opts?: { force?: boolean }): Promise { // If we're already closing and this isn't a forced close, no-op. diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts new file mode 100644 index 000000000..f0c106cd4 --- /dev/null +++ b/packages/core/tests/cookies.test.ts @@ -0,0 +1,1335 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + filterCookies, + normalizeCookieParams, + cookieMatchesFilter, + type Cookie, + type CookieParam, +} from "../lib/v3/understudy/cookies"; +import { MockCDPSession } from "./helpers/mockCDPSession"; +import type { V3Context } from "../lib/v3/understudy/context"; + +// --------------------------------------------------------------------------- +// Helpers: mock cookie factory +// --------------------------------------------------------------------------- + +function makeCookie(overrides: Partial = {}): Cookie { + return { + name: "sid", + value: "abc123", + domain: "example.com", + path: "/", + expires: -1, + httpOnly: false, + secure: false, + sameSite: "Lax", + ...overrides, + }; +} + +/** Convert our Cookie type into the shape CDP's Network.getAllCookies returns. */ +function toCdpCookie(c: Cookie) { + return { + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + expires: c.expires, + httpOnly: c.httpOnly, + secure: c.secure, + sameSite: c.sameSite, + size: c.name.length + c.value.length, + session: c.expires === -1, + priority: "Medium", + sameParty: false, + sourceScheme: "Secure", + sourcePort: 443, + }; +} + +// ============================================================================ +// filterCookies +// ============================================================================ + +describe("filterCookies", () => { + const cookies: Cookie[] = [ + makeCookie({ name: "a", domain: "example.com", path: "/", secure: false }), + makeCookie({ + name: "b", + domain: ".example.com", + path: "/app", + secure: true, + }), + makeCookie({ name: "c", domain: "other.com", path: "/", secure: false }), + makeCookie({ + name: "d", + domain: "sub.example.com", + path: "/", + secure: false, + }), + ]; + + it("returns all cookies when urls is empty", () => { + expect(filterCookies(cookies, [])).toEqual(cookies); + }); + + it("filters by domain (exact host match)", () => { + const result = filterCookies(cookies, ["http://example.com/"]); + const names = result.map((c) => c.name); + expect(names).toContain("a"); + // "b" (.example.com) domain-matches but is secure — excluded on http:// + expect(names).not.toContain("b"); + expect(names).not.toContain("c"); + expect(names).not.toContain("d"); + }); + + it("filters by domain (dot-prefixed domain matches on https)", () => { + const result = filterCookies(cookies, ["https://example.com/app/settings"]); + const names = result.map((c) => c.name); + expect(names).toContain("a"); // example.com domain match, path "/" prefix + expect(names).toContain("b"); // .example.com domain match + secure + https + }); + + it("filters by domain (subdomain matches dot-prefixed domain)", () => { + const result = filterCookies(cookies, ["http://sub.example.com/"]); + const names = result.map((c) => c.name); + // "a" (example.com) → prepend dot → .example.com → matches .sub.example.com + expect(names).toContain("a"); + // "b" (.example.com) domain-matches sub.example.com but is secure — excluded on http:// + expect(names).not.toContain("b"); + expect(names).toContain("d"); // sub.example.com matches exactly + expect(names).not.toContain("c"); + }); + + it("filters by path prefix", () => { + const result = filterCookies(cookies, ["https://example.com/app/settings"]); + const names = result.map((c) => c.name); + expect(names).toContain("a"); // path "/" is a prefix of "/app/settings" + expect(names).toContain("b"); // path "/app" is a prefix of "/app/settings" + }); + + it("excludes secure cookies for non-https URLs", () => { + const result = filterCookies(cookies, ["http://example.com/app/page"]); + const names = result.map((c) => c.name); + expect(names).toContain("a"); + expect(names).not.toContain("b"); // secure cookie, http URL + }); + + it("allows secure cookies on localhost regardless of protocol", () => { + const localCookie = makeCookie({ + name: "local", + domain: "localhost", + secure: true, + }); + const result = filterCookies([localCookie], ["http://localhost/"]); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe("local"); + }); + + it("matches against multiple URLs (union)", () => { + const result = filterCookies(cookies, [ + "http://example.com/", + "http://other.com/", + ]); + const names = result.map((c) => c.name); + expect(names).toContain("a"); + expect(names).toContain("c"); + }); + + it("returns empty array when no cookies match any URL", () => { + const result = filterCookies(cookies, ["http://nomatch.dev/"]); + expect(result).toHaveLength(0); + }); + + it("returns empty array when cookie list is empty", () => { + const result = filterCookies([], ["http://example.com/"]); + expect(result).toHaveLength(0); + }); + + it("does not match a sibling subdomain against a host-only domain", () => { + // Cookie for "api.example.com" should NOT match "www.example.com" + const apiCookie = makeCookie({ name: "api", domain: "api.example.com" }); + const result = filterCookies([apiCookie], ["http://www.example.com/"]); + expect(result).toHaveLength(0); + }); + + it("does not match a parent domain against a more specific cookie", () => { + // Cookie for "sub.example.com" should NOT match "example.com" + const subCookie = makeCookie({ name: "sub", domain: "sub.example.com" }); + const result = filterCookies([subCookie], ["http://example.com/"]); + expect(result).toHaveLength(0); + }); + + it("does not match when path does not prefix the URL path", () => { + const deepCookie = makeCookie({ + name: "deep", + domain: "example.com", + path: "/admin", + }); + const result = filterCookies([deepCookie], ["http://example.com/public"]); + expect(result).toHaveLength(0); + }); + + it("matches root path against any URL path", () => { + const rootCookie = makeCookie({ + name: "root", + domain: "example.com", + path: "/", + }); + const result = filterCookies( + [rootCookie], + ["http://example.com/deeply/nested/page"], + ); + expect(result).toHaveLength(1); + }); + + it("handles URL with port numbers", () => { + const c = makeCookie({ name: "port", domain: "localhost", path: "/" }); + const result = filterCookies([c], ["http://localhost:3000/api"]); + expect(result).toHaveLength(1); + }); + + it("handles URL with query string and fragment", () => { + const c = makeCookie({ name: "q", domain: "example.com", path: "/" }); + const result = filterCookies( + [c], + ["http://example.com/page?q=1&r=2#section"], + ); + expect(result).toHaveLength(1); + }); +}); + +// ============================================================================ +// normalizeCookieParams +// ============================================================================ + +describe("normalizeCookieParams", () => { + it("passes through cookies with domain+path", () => { + const input: CookieParam[] = [ + { name: "a", value: "1", domain: "example.com", path: "/" }, + ]; + const result = normalizeCookieParams(input); + expect(result[0]!.domain).toBe("example.com"); + expect(result[0]!.path).toBe("/"); + expect(result[0]!.url).toBeUndefined(); + }); + + it("derives domain, path, and secure from url", () => { + const input: CookieParam[] = [ + { name: "a", value: "1", url: "https://example.com/app/page" }, + ]; + const result = normalizeCookieParams(input); + expect(result[0]!.domain).toBe("example.com"); + expect(result[0]!.path).toBe("/app/"); + expect(result[0]!.secure).toBe(true); + expect(result[0]!.url).toBeUndefined(); + }); + + it("sets secure to false for http urls", () => { + const input: CookieParam[] = [ + { name: "a", value: "1", url: "http://example.com/" }, + ]; + const result = normalizeCookieParams(input); + expect(result[0]!.secure).toBe(false); + }); + + it("throws when neither url nor domain+path is provided", () => { + expect(() => normalizeCookieParams([{ name: "a", value: "1" }])).toThrow( + /must have a url or a domain\/path pair/, + ); + }); + + it("throws when both url and domain are provided", () => { + expect(() => + normalizeCookieParams([ + { name: "a", value: "1", url: "https://x.com/", domain: "x.com" }, + ]), + ).toThrow(/should have either url or domain/); + }); + + it("throws when both url and path are provided", () => { + expect(() => + normalizeCookieParams([ + { name: "a", value: "1", url: "https://x.com/", path: "/" }, + ]), + ).toThrow(/should have either url or path/); + }); + + it("throws for invalid expires (negative, not -1)", () => { + expect(() => + normalizeCookieParams([ + { name: "a", value: "1", domain: "x.com", path: "/", expires: -5 }, + ]), + ).toThrow(/invalid expires/); + }); + + it("allows expires of -1 (session cookie)", () => { + const result = normalizeCookieParams([ + { name: "a", value: "1", domain: "x.com", path: "/", expires: -1 }, + ]); + expect(result[0]!.expires).toBe(-1); + }); + + it("allows a positive expires timestamp", () => { + const future = Math.floor(Date.now() / 1000) + 3600; + const result = normalizeCookieParams([ + { name: "a", value: "1", domain: "x.com", path: "/", expires: future }, + ]); + expect(result[0]!.expires).toBe(future); + }); + + it("throws for about:blank url", () => { + expect(() => + normalizeCookieParams([{ name: "a", value: "1", url: "about:blank" }]), + ).toThrow(/Blank page/); + }); + + it("throws for data: url", () => { + expect(() => + normalizeCookieParams([ + { name: "a", value: "1", url: "data:text/html,hi" }, + ]), + ).toThrow(/Data URL/); + }); + + it("throws when sameSite is None but secure is false", () => { + expect(() => + normalizeCookieParams([ + { + name: "a", + value: "1", + domain: "x.com", + path: "/", + sameSite: "None", + secure: false, + }, + ]), + ).toThrow(/sameSite: "None" but secure: false/); + }); + + it("does NOT throw when sameSite is None and secure is true", () => { + const result = normalizeCookieParams([ + { + name: "a", + value: "1", + domain: "x.com", + path: "/", + sameSite: "None", + secure: true, + }, + ]); + expect(result[0]!.sameSite).toBe("None"); + expect(result[0]!.secure).toBe(true); + }); + + it("does NOT throw when sameSite is None and secure is undefined (not explicitly false)", () => { + // secure is undefined — the browser will decide, we don't block it + const result = normalizeCookieParams([ + { name: "a", value: "1", domain: "x.com", path: "/", sameSite: "None" }, + ]); + expect(result[0]!.sameSite).toBe("None"); + }); + + it("derives root path from URL with no trailing path segments", () => { + const result = normalizeCookieParams([ + { name: "a", value: "1", url: "https://example.com" }, + ]); + // URL("https://example.com").pathname is "/", lastIndexOf("/") + 1 = 1 → "/" + expect(result[0]!.path).toBe("/"); + }); + + it("handles URL with port number", () => { + const result = normalizeCookieParams([ + { name: "a", value: "1", url: "https://localhost:3000/api/v1" }, + ]); + expect(result[0]!.domain).toBe("localhost"); + expect(result[0]!.path).toBe("/api/"); + expect(result[0]!.secure).toBe(true); + }); + + it("handles URL with query string (ignores query)", () => { + const result = normalizeCookieParams([ + { name: "a", value: "1", url: "https://example.com/page?q=1" }, + ]); + expect(result[0]!.domain).toBe("example.com"); + expect(result[0]!.path).toBe("/"); + }); + + it("normalises multiple cookies in a single call", () => { + const result = normalizeCookieParams([ + { name: "a", value: "1", url: "https://one.com/x" }, + { name: "b", value: "2", domain: "two.com", path: "/" }, + { name: "c", value: "3", url: "http://three.com/y/z" }, + ]); + expect(result).toHaveLength(3); + expect(result[0]!.domain).toBe("one.com"); + expect(result[1]!.domain).toBe("two.com"); + expect(result[2]!.domain).toBe("three.com"); + expect(result[2]!.secure).toBe(false); + }); + + it("does not mutate the original input array", () => { + const input: CookieParam[] = [ + { name: "a", value: "1", url: "https://example.com/app" }, + ]; + const frozen = { ...input[0]! }; + normalizeCookieParams(input); + expect(input[0]).toEqual(frozen); + }); + + it("preserves optional fields that are explicitly set", () => { + const result = normalizeCookieParams([ + { + name: "full", + value: "val", + domain: "x.com", + path: "/p", + expires: 9999999999, + httpOnly: true, + secure: true, + sameSite: "Strict", + }, + ]); + const c = result[0]!; + expect(c.httpOnly).toBe(true); + expect(c.secure).toBe(true); + expect(c.sameSite).toBe("Strict"); + expect(c.expires).toBe(9999999999); + }); + + it("allows expires of 0 (epoch — effectively expired)", () => { + // 0 is a positive-ish edge case; browsers treat it as already expired + const result = normalizeCookieParams([ + { name: "a", value: "1", domain: "x.com", path: "/", expires: 0 }, + ]); + expect(result[0]!.expires).toBe(0); + }); + + it("throws on the first invalid cookie in a batch", () => { + expect(() => + normalizeCookieParams([ + { name: "ok", value: "1", domain: "x.com", path: "/" }, + { name: "bad", value: "2" }, // missing url/domain+path + ]), + ).toThrow(/Cookie "bad"/); + }); + + it("includes cookie name in every error message", () => { + const cases = [ + () => normalizeCookieParams([{ name: "NAMED", value: "1" }]), + () => + normalizeCookieParams([ + { name: "NAMED", value: "1", url: "https://x.com/", domain: "x" }, + ]), + () => + normalizeCookieParams([ + { name: "NAMED", value: "1", url: "about:blank" }, + ]), + () => + normalizeCookieParams([ + { + name: "NAMED", + value: "1", + domain: "x.com", + path: "/", + sameSite: "None", + secure: false, + }, + ]), + ]; + for (const fn of cases) { + expect(fn).toThrow(/NAMED/); + } + }); +}); + +// ============================================================================ +// cookieMatchesFilter +// ============================================================================ + +describe("cookieMatchesFilter", () => { + const cookie = makeCookie({ + name: "session", + domain: ".example.com", + path: "/app", + }); + + it("matches when all filters match (exact strings)", () => { + expect( + cookieMatchesFilter(cookie, { + name: "session", + domain: ".example.com", + path: "/app", + }), + ).toBe(true); + }); + + it("does not match when name differs", () => { + expect(cookieMatchesFilter(cookie, { name: "other" })).toBe(false); + }); + + it("does not match when domain differs", () => { + expect(cookieMatchesFilter(cookie, { domain: "other.com" })).toBe(false); + }); + + it("does not match when path differs", () => { + expect(cookieMatchesFilter(cookie, { path: "/other" })).toBe(false); + }); + + it("matches with regex name", () => { + expect(cookieMatchesFilter(cookie, { name: /^sess/ })).toBe(true); + expect(cookieMatchesFilter(cookie, { name: /^nope/ })).toBe(false); + }); + + it("matches with regex domain", () => { + expect(cookieMatchesFilter(cookie, { domain: /example\.com$/ })).toBe(true); + expect(cookieMatchesFilter(cookie, { domain: /^other/ })).toBe(false); + }); + + it("matches with regex path", () => { + expect(cookieMatchesFilter(cookie, { path: /^\/app/ })).toBe(true); + }); + + it("undefined filters match everything", () => { + expect(cookieMatchesFilter(cookie, {})).toBe(true); + expect(cookieMatchesFilter(cookie, { name: undefined })).toBe(true); + }); + + it("requires ALL filters to match (AND logic)", () => { + // name matches but domain does not + expect( + cookieMatchesFilter(cookie, { name: "session", domain: "wrong.com" }), + ).toBe(false); + }); + + it("handles global regex lastIndex correctly", () => { + const re = /sess/g; + re.lastIndex = 999; + expect(cookieMatchesFilter(cookie, { name: re })).toBe(true); + }); + + it("exact string does not do substring matching", () => { + // filter name "sess" should NOT match cookie name "session" + expect(cookieMatchesFilter(cookie, { name: "sess" })).toBe(false); + }); + + it("regex can do substring matching", () => { + // regex /sess/ SHOULD match cookie name "session" (substring) + expect(cookieMatchesFilter(cookie, { name: /sess/ })).toBe(true); + }); + + it("works with all three regex filters combined", () => { + expect( + cookieMatchesFilter(cookie, { + name: /^session$/, + domain: /example/, + path: /^\/app$/, + }), + ).toBe(true); + + // One of three fails + expect( + cookieMatchesFilter(cookie, { + name: /^session$/, + domain: /example/, + path: /^\/wrong$/, + }), + ).toBe(false); + }); + + it("empty string filter only matches empty cookie property", () => { + const emptyPathCookie = makeCookie({ + name: "x", + domain: "a.com", + path: "", + }); + expect(cookieMatchesFilter(emptyPathCookie, { path: "" })).toBe(true); + expect(cookieMatchesFilter(cookie, { path: "" })).toBe(false); + }); + + it("is called once per cookie (no cross-contamination between calls)", () => { + const c1 = makeCookie({ name: "alpha", domain: "a.com", path: "/" }); + const c2 = makeCookie({ name: "beta", domain: "b.com", path: "/x" }); + const filter = { name: "alpha", domain: "a.com" }; + expect(cookieMatchesFilter(c1, filter)).toBe(true); + expect(cookieMatchesFilter(c2, filter)).toBe(false); + }); +}); + +// ============================================================================ +// V3Context cookie methods (integration with MockCDPSession) +// ============================================================================ + +describe("V3Context cookie methods", () => { + // We test V3Context methods by constructing a minimal instance with a mock + // CDP connection. V3Context.create() requires a real WebSocket, so we build + // one via type-casting a MockCDPSession into the `conn` slot. + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let V3ContextClass: { prototype: V3Context } & Record; + + beforeEach(async () => { + const mod = await import("../lib/v3/understudy/context"); + V3ContextClass = mod.V3Context as typeof V3ContextClass; + }); + + function makeContext( + cdpHandlers: Record) => unknown>, + ): V3Context { + const mockConn = new MockCDPSession(cdpHandlers, "root"); + // V3Context stores the connection as `conn` (readonly). We create an + // object with the real prototype so we get the actual method implementations. + const ctx = Object.create(V3ContextClass.prototype) as V3Context & { + conn: MockCDPSession; + }; + // Assign the mock connection + Object.defineProperty(ctx, "conn", { value: mockConn, writable: false }); + return ctx; + } + + function getMockConn(ctx: V3Context): MockCDPSession { + return (ctx as unknown as { conn: MockCDPSession }).conn; + } + + // ---------- cookies() ---------- + + describe("cookies()", () => { + it("returns all cookies from Network.getAllCookies", async () => { + const cdpCookies = [ + toCdpCookie(makeCookie({ name: "a", domain: "example.com" })), + toCdpCookie(makeCookie({ name: "b", domain: "other.com" })), + ]; + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: cdpCookies }), + }); + + const result = await ctx.cookies(); + expect(result).toHaveLength(2); + expect(result.map((c) => c.name)).toEqual(["a", "b"]); + }); + + it("filters by URL when provided as string", async () => { + const cdpCookies = [ + toCdpCookie(makeCookie({ name: "a", domain: "example.com" })), + toCdpCookie(makeCookie({ name: "b", domain: "other.com" })), + ]; + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: cdpCookies }), + }); + + const result = await ctx.cookies("http://example.com/"); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe("a"); + }); + + it("filters by URL when provided as array", async () => { + const cdpCookies = [ + toCdpCookie(makeCookie({ name: "a", domain: "example.com" })), + toCdpCookie(makeCookie({ name: "b", domain: "other.com" })), + ]; + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: cdpCookies }), + }); + + const result = await ctx.cookies(["http://other.com/"]); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe("b"); + }); + + it("defaults sameSite to Lax when CDP returns undefined", async () => { + const cdpCookie = { + ...toCdpCookie(makeCookie()), + sameSite: undefined as string | undefined, + }; + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [cdpCookie] }), + }); + + const result = await ctx.cookies(); + expect(result[0]!.sameSite).toBe("Lax"); + }); + + it("returns empty array when browser has no cookies", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [] }), + }); + const result = await ctx.cookies(); + expect(result).toEqual([]); + }); + + it("maps all CDP cookie fields to our Cookie type", async () => { + const cdpCookie = toCdpCookie( + makeCookie({ + name: "full", + value: "v", + domain: ".test.com", + path: "/p", + expires: 1700000000, + httpOnly: true, + secure: true, + sameSite: "Strict", + }), + ); + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [cdpCookie] }), + }); + + const result = await ctx.cookies(); + expect(result[0]).toEqual({ + name: "full", + value: "v", + domain: ".test.com", + path: "/p", + expires: 1700000000, + httpOnly: true, + secure: true, + sameSite: "Strict", + }); + }); + + it("strips extra CDP fields (size, priority, etc.) from result", async () => { + const cdpCookie = toCdpCookie(makeCookie({ name: "stripped" })); + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [cdpCookie] }), + }); + + const result = await ctx.cookies(); + const keys = Object.keys(result[0]!); + expect(keys).not.toContain("size"); + expect(keys).not.toContain("priority"); + expect(keys).not.toContain("sourceScheme"); + expect(keys).not.toContain("sourcePort"); + }); + + it("calls Network.getAllCookies exactly once per invocation", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [] }), + }); + + await ctx.cookies(); + await ctx.cookies("http://example.com"); + + const calls = getMockConn(ctx).callsFor("Network.getAllCookies"); + expect(calls).toHaveLength(2); + }); + }); + + // ---------- addCookies() ---------- + + describe("addCookies()", () => { + it("sends Network.setCookie for each cookie", async () => { + const ctx = makeContext({ + "Network.setCookie": () => ({ success: true }), + }); + + await ctx.addCookies([ + { name: "a", value: "1", domain: "example.com", path: "/" }, + { name: "b", value: "2", domain: "other.com", path: "/" }, + ]); + + const calls = getMockConn(ctx).callsFor("Network.setCookie"); + expect(calls).toHaveLength(2); + expect(calls[0]!.params).toMatchObject({ + name: "a", + domain: "example.com", + }); + expect(calls[1]!.params).toMatchObject({ + name: "b", + domain: "other.com", + }); + }); + + it("derives domain/path/secure from url", async () => { + const ctx = makeContext({ + "Network.setCookie": () => ({ success: true }), + }); + + await ctx.addCookies([ + { name: "a", value: "1", url: "https://example.com/app/page" }, + ]); + + const calls = getMockConn(ctx).callsFor("Network.setCookie"); + expect(calls[0]!.params).toMatchObject({ + name: "a", + domain: "example.com", + path: "/app/", + secure: true, + }); + }); + + it("throws when Network.setCookie returns success: false", async () => { + const ctx = makeContext({ + "Network.setCookie": () => ({ success: false }), + }); + + await expect( + ctx.addCookies([ + { name: "bad", value: "x", domain: "example.com", path: "/" }, + ]), + ).rejects.toThrow(/Failed to set cookie "bad"/); + }); + + it("throws for sameSite None without secure", async () => { + const ctx = makeContext({ + "Network.setCookie": () => ({ success: true }), + }); + + await expect( + ctx.addCookies([ + { + name: "x", + value: "1", + domain: "example.com", + path: "/", + sameSite: "None", + secure: false, + }, + ]), + ).rejects.toThrow(/sameSite: "None" but secure: false/); + }); + + it("does nothing when passed an empty array", async () => { + const ctx = makeContext({ + "Network.setCookie": () => ({ success: true }), + }); + + await ctx.addCookies([]); + + const calls = getMockConn(ctx).callsFor("Network.setCookie"); + expect(calls).toHaveLength(0); + }); + + it("sends all cookie fields to CDP (including optional ones)", async () => { + const ctx = makeContext({ + "Network.setCookie": () => ({ success: true }), + }); + + await ctx.addCookies([ + { + name: "full", + value: "val", + domain: "x.com", + path: "/p", + expires: 9999999999, + httpOnly: true, + secure: true, + sameSite: "Strict", + }, + ]); + + const calls = getMockConn(ctx).callsFor("Network.setCookie"); + expect(calls[0]!.params).toEqual({ + name: "full", + value: "val", + domain: "x.com", + path: "/p", + expires: 9999999999, + httpOnly: true, + secure: true, + sameSite: "Strict", + }); + }); + + it("stops on first failure and does not continue to remaining cookies", async () => { + let callCount = 0; + const ctx = makeContext({ + "Network.setCookie": () => { + callCount++; + // First succeeds, second fails + return { success: callCount <= 1 }; + }, + }); + + await expect( + ctx.addCookies([ + { name: "ok", value: "1", domain: "a.com", path: "/" }, + { name: "fail", value: "2", domain: "b.com", path: "/" }, + { name: "never", value: "3", domain: "c.com", path: "/" }, + ]), + ).rejects.toThrow(/Failed to set cookie "fail"/); + + // "never" should not have been attempted + expect(callCount).toBe(2); + }); + + it("error message includes the domain when setCookie fails", async () => { + const ctx = makeContext({ + "Network.setCookie": () => ({ success: false }), + }); + + await expect( + ctx.addCookies([ + { name: "x", value: "1", domain: "specific.com", path: "/" }, + ]), + ).rejects.toThrow(/specific\.com/); + }); + }); + + // ---------- clearCookies() ---------- + + describe("clearCookies()", () => { + const cdpCookies = [ + toCdpCookie( + makeCookie({ name: "session", domain: "example.com", path: "/" }), + ), + toCdpCookie( + makeCookie({ name: "_ga", domain: ".example.com", path: "/" }), + ), + toCdpCookie( + makeCookie({ name: "pref", domain: "other.com", path: "/settings" }), + ), + ]; + + it("deletes ALL cookies when called with no options", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), + "Network.deleteCookies": () => ({}), + }); + + await ctx.clearCookies(); + + const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); + expect(deleteCalls).toHaveLength(3); + }); + + it("deletes only cookies matching a name filter", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), + "Network.deleteCookies": () => ({}), + }); + + await ctx.clearCookies({ name: "_ga" }); + + const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); + expect(deleteCalls).toHaveLength(1); + expect(deleteCalls[0]!.params).toMatchObject({ name: "_ga" }); + }); + + it("deletes only cookies matching a domain filter", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), + "Network.deleteCookies": () => ({}), + }); + + await ctx.clearCookies({ domain: "other.com" }); + + const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); + expect(deleteCalls).toHaveLength(1); + expect(deleteCalls[0]!.params).toMatchObject({ + name: "pref", + domain: "other.com", + }); + }); + + it("deletes cookies matching a regex pattern", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), + "Network.deleteCookies": () => ({}), + }); + + await ctx.clearCookies({ name: /^_ga/ }); + + const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); + expect(deleteCalls).toHaveLength(1); + expect(deleteCalls[0]!.params).toMatchObject({ name: "_ga" }); + }); + + it("applies AND logic across multiple filters", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), + "Network.deleteCookies": () => ({}), + }); + + // name matches "session" AND domain matches "example.com" + await ctx.clearCookies({ name: "session", domain: "example.com" }); + + const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); + expect(deleteCalls).toHaveLength(1); + expect(deleteCalls[0]!.params).toMatchObject({ + name: "session", + domain: "example.com", + }); + }); + + it("does not delete non-matching cookies (no nuke-and-re-add)", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), + "Network.deleteCookies": () => ({}), + "Network.setCookie": () => ({ success: true }), + }); + + await ctx.clearCookies({ name: "session" }); + + // Should NOT have called setCookie (no re-add needed) + const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); + expect(setCalls).toHaveLength(0); + + // Should only have deleted the one matching cookie + const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); + expect(deleteCalls).toHaveLength(1); + }); + + it("handles empty cookie jar gracefully", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [] }), + "Network.deleteCookies": () => ({}), + }); + + await ctx.clearCookies(); + + const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); + expect(deleteCalls).toHaveLength(0); + }); + + it("deletes nothing when filter matches no cookies", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), + "Network.deleteCookies": () => ({}), + }); + + await ctx.clearCookies({ name: "nonexistent" }); + + const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); + expect(deleteCalls).toHaveLength(0); + }); + + it("sends correct domain and path for each deleted cookie", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), + "Network.deleteCookies": () => ({}), + }); + + await ctx.clearCookies(); // delete all + + const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); + expect(deleteCalls).toHaveLength(3); + expect(deleteCalls[0]!.params).toMatchObject({ + name: "session", + domain: "example.com", + path: "/", + }); + expect(deleteCalls[1]!.params).toMatchObject({ + name: "_ga", + domain: ".example.com", + path: "/", + }); + expect(deleteCalls[2]!.params).toMatchObject({ + name: "pref", + domain: "other.com", + path: "/settings", + }); + }); + + it("handles regex that matches multiple cookies", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ + cookies: [ + toCdpCookie( + makeCookie({ name: "_ga_ABC", domain: "example.com", path: "/" }), + ), + toCdpCookie( + makeCookie({ name: "_ga_DEF", domain: "example.com", path: "/" }), + ), + toCdpCookie( + makeCookie({ name: "_gid", domain: "example.com", path: "/" }), + ), + toCdpCookie( + makeCookie({ name: "session", domain: "example.com", path: "/" }), + ), + ], + }), + "Network.deleteCookies": () => ({}), + }); + + await ctx.clearCookies({ name: /^_ga/ }); + + const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); + expect(deleteCalls).toHaveLength(2); + const deletedNames = deleteCalls.map((c) => c.params?.name); + expect(deletedNames).toContain("_ga_ABC"); + expect(deletedNames).toContain("_ga_DEF"); + expect(deletedNames).not.toContain("_gid"); + expect(deletedNames).not.toContain("session"); + }); + + it("regex domain filter combined with path filter", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), + "Network.deleteCookies": () => ({}), + }); + + // Match domain containing "example" AND path "/settings" + // Only "pref" has path "/settings" but domain is "other.com" — no match + // No cookie has both domain matching /example/ AND path "/settings" + await ctx.clearCookies({ domain: /example/, path: "/settings" }); + + const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); + expect(deleteCalls).toHaveLength(0); + }); + + it("clearCookies with empty options object deletes all (same as no args)", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), + "Network.deleteCookies": () => ({}), + }); + + await ctx.clearCookies({}); + + const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); + expect(deleteCalls).toHaveLength(3); + }); + }); + + // ---------- storageState() ---------- + + describe("storageState()", () => { + it("returns a snapshot with all cookies", async () => { + const cdpCookies = [ + toCdpCookie(makeCookie({ name: "a" })), + toCdpCookie(makeCookie({ name: "b" })), + ]; + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: cdpCookies }), + }); + + const state = await ctx.storageState(); + expect(state.cookies).toHaveLength(2); + expect(state.cookies.map((c) => c.name)).toEqual(["a", "b"]); + }); + + it("returns empty cookies array when browser has none", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [] }), + }); + + const state = await ctx.storageState(); + expect(state.cookies).toEqual([]); + }); + + it("snapshot is JSON-serialisable (round-trip)", async () => { + const cdpCookies = [ + toCdpCookie( + makeCookie({ + name: "ser", + value: "v", + domain: "x.com", + path: "/", + expires: 9999999999, + httpOnly: true, + secure: true, + sameSite: "Strict", + }), + ), + ]; + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: cdpCookies }), + }); + + const state = await ctx.storageState(); + const json = JSON.stringify(state); + const parsed = JSON.parse(json) as { cookies: Cookie[] }; + expect(parsed.cookies[0]).toEqual(state.cookies[0]); + }); + }); + + // ---------- setStorageState() ---------- + + describe("setStorageState()", () => { + it("clears existing cookies then restores from snapshot", async () => { + const callOrder: string[] = []; + const ctx = makeContext({ + "Network.getAllCookies": () => { + callOrder.push("getAllCookies"); + return { cookies: [toCdpCookie(makeCookie({ name: "old" }))] }; + }, + "Network.deleteCookies": () => { + callOrder.push("deleteCookies"); + return {}; + }, + "Network.setCookie": () => { + callOrder.push("setCookie"); + return { success: true }; + }, + }); + + await ctx.setStorageState({ + cookies: [ + makeCookie({ name: "restored", domain: "example.com", path: "/" }), + ], + }); + + // Should have cleared first, then set + expect(callOrder).toEqual([ + "getAllCookies", // from clearCookies + "deleteCookies", // delete the "old" cookie + "setCookie", // add the "restored" cookie + ]); + + const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); + expect(setCalls[0]!.params).toMatchObject({ name: "restored" }); + }); + + it("skips expired cookies from the snapshot", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [] }), + "Network.setCookie": () => ({ success: true }), + }); + + const pastTimestamp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago + const futureTimestamp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now + + await ctx.setStorageState({ + cookies: [ + makeCookie({ name: "expired", expires: pastTimestamp }), + makeCookie({ name: "valid", expires: futureTimestamp }), + makeCookie({ name: "session", expires: -1 }), + ], + }); + + const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); + expect(setCalls).toHaveLength(2); + const setNames = setCalls.map((c) => c.params?.name); + expect(setNames).toContain("valid"); + expect(setNames).toContain("session"); + expect(setNames).not.toContain("expired"); + }); + + it("handles empty snapshot gracefully", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [] }), + "Network.setCookie": () => ({ success: true }), + }); + + await ctx.setStorageState({ cookies: [] }); + + const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); + expect(setCalls).toHaveLength(0); + }); + + it("keeps session cookies (expires === -1) from snapshot", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [] }), + "Network.setCookie": () => ({ success: true }), + }); + + await ctx.setStorageState({ + cookies: [makeCookie({ name: "sess", expires: -1 })], + }); + + const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); + expect(setCalls).toHaveLength(1); + expect(setCalls[0]!.params).toMatchObject({ name: "sess" }); + }); + + it("skips all cookies when entire snapshot is expired", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [] }), + "Network.setCookie": () => ({ success: true }), + }); + + const pastTimestamp = Math.floor(Date.now() / 1000) - 1; + + await ctx.setStorageState({ + cookies: [ + makeCookie({ name: "old1", expires: pastTimestamp }), + makeCookie({ name: "old2", expires: pastTimestamp - 1000 }), + ], + }); + + const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); + expect(setCalls).toHaveLength(0); + }); + + it("clears existing cookies even when snapshot is empty", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ + cookies: [toCdpCookie(makeCookie({ name: "existing" }))], + }), + "Network.deleteCookies": () => ({}), + "Network.setCookie": () => ({ success: true }), + }); + + await ctx.setStorageState({ cookies: [] }); + + const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); + expect(deleteCalls).toHaveLength(1); + expect(deleteCalls[0]!.params).toMatchObject({ name: "existing" }); + + const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); + expect(setCalls).toHaveLength(0); + }); + + it("cookie right at the expiry boundary (now) is treated as expired", async () => { + const ctx = makeContext({ + "Network.getAllCookies": () => ({ cookies: [] }), + "Network.setCookie": () => ({ success: true }), + }); + + // Exactly now — should be filtered out (not strictly greater) + const nowSeconds = Math.floor(Date.now() / 1000); + + await ctx.setStorageState({ + cookies: [makeCookie({ name: "boundary", expires: nowSeconds })], + }); + + // The expires check is `c.expires > nowSeconds`. Since Date.now() may + // advance by the time setStorageState runs, this cookie is at the edge. + // It should either be filtered or just barely pass — both are acceptable. + // What matters is that clearly-expired cookies don't slip through. + const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); + expect(setCalls.length).toBeLessThanOrEqual(1); + }); + + it("full round-trip: storageState → setStorageState preserves cookies", async () => { + const original = [ + toCdpCookie( + makeCookie({ + name: "auth", + value: "token123", + domain: "app.com", + path: "/", + expires: -1, + }), + ), + toCdpCookie( + makeCookie({ + name: "theme", + value: "dark", + domain: "app.com", + path: "/", + expires: Math.floor(Date.now() / 1000) + 86400, + }), + ), + ]; + + // Phase 1: snapshot + const ctx1 = makeContext({ + "Network.getAllCookies": () => ({ cookies: original }), + }); + const state = await ctx1.storageState(); + expect(state.cookies).toHaveLength(2); + + // Phase 2: restore into a "fresh" context + const setCookieParams: Record[] = []; + const ctx2 = makeContext({ + "Network.getAllCookies": () => ({ cookies: [] }), + "Network.deleteCookies": () => ({}), + "Network.setCookie": (params) => { + setCookieParams.push(params ?? {}); + return { success: true }; + }, + }); + await ctx2.setStorageState(state); + + expect(setCookieParams).toHaveLength(2); + expect(setCookieParams[0]).toMatchObject({ + name: "auth", + value: "token123", + }); + expect(setCookieParams[1]).toMatchObject({ + name: "theme", + value: "dark", + }); + }); + }); +}); From 4abb678c91f222f1d9e1fd02bf84f2767b40097c Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 11 Feb 2026 15:34:44 -0800 Subject: [PATCH 02/22] add support for setting and getting cookies --- .changeset/fast-buses-kneel.md | 5 ++ .../tests/public-api/public-types.test.ts | 62 +++++++++++++++++++ .../core/tests/public-api/v3-core.test.ts | 34 ++++++++++ 3 files changed, 101 insertions(+) create mode 100644 .changeset/fast-buses-kneel.md diff --git a/.changeset/fast-buses-kneel.md b/.changeset/fast-buses-kneel.md new file mode 100644 index 000000000..895cca473 --- /dev/null +++ b/.changeset/fast-buses-kneel.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": patch +--- + +Add native support for setting and getting cookies diff --git a/packages/core/tests/public-api/public-types.test.ts b/packages/core/tests/public-api/public-types.test.ts index d5a5fae44..e80184dac 100644 --- a/packages/core/tests/public-api/public-types.test.ts +++ b/packages/core/tests/public-api/public-types.test.ts @@ -97,6 +97,11 @@ type ExpectedExportedTypes = { // Types from utils.ts JsonSchema: Stagehand.JsonSchema; JsonSchemaProperty: Stagehand.JsonSchemaProperty; + // Types from cookies.ts + Cookie: Stagehand.Cookie; + CookieParam: Stagehand.CookieParam; + ClearCookieOptions: Stagehand.ClearCookieOptions; + StorageState: Stagehand.StorageState; }; describe("Stagehand public API types", () => { @@ -298,4 +303,61 @@ describe("Stagehand public API types", () => { expectTypeOf().toEqualTypeOf(); }); }); + + describe("Cookie", () => { + type ExpectedCookie = { + name: string; + value: string; + domain: string; + path: string; + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite: "Strict" | "Lax" | "None"; + }; + + it("matches expected type shape", () => { + expectTypeOf().toEqualTypeOf(); + }); + }); + + describe("CookieParam", () => { + type ExpectedCookieParam = { + name: string; + value: string; + url?: string; + domain?: string; + path?: string; + expires?: number; + httpOnly?: boolean; + secure?: boolean; + sameSite?: "Strict" | "Lax" | "None"; + }; + + it("matches expected type shape", () => { + expectTypeOf().toEqualTypeOf(); + }); + }); + + describe("ClearCookieOptions", () => { + type ExpectedClearCookieOptions = { + name?: string | RegExp; + domain?: string | RegExp; + path?: string | RegExp; + }; + + it("matches expected type shape", () => { + expectTypeOf().toEqualTypeOf(); + }); + }); + + describe("StorageState", () => { + type ExpectedStorageState = { + cookies: Stagehand.Cookie[]; + }; + + it("matches expected type shape", () => { + expectTypeOf().toEqualTypeOf(); + }); + }); }); diff --git a/packages/core/tests/public-api/v3-core.test.ts b/packages/core/tests/public-api/v3-core.test.ts index 59b5e0cea..0d8fe1bd3 100644 --- a/packages/core/tests/public-api/v3-core.test.ts +++ b/packages/core/tests/public-api/v3-core.test.ts @@ -31,6 +31,10 @@ describe("V3 Core public API types", () => { logger: (logLine: Stagehand.LogLine) => void; isAgentReplayActive: () => boolean; recordAgentReplayStep: (step: unknown) => void; + // Cookie management methods + cookies: (urls?: string | string[]) => Promise; + addCookies: (cookies: Stagehand.CookieParam[]) => Promise; + clearCookies: (options?: Stagehand.ClearCookieOptions) => Promise; }; type StagehandInstance = InstanceType; @@ -71,6 +75,36 @@ describe("V3 Core public API types", () => { page: mockPage, } satisfies Stagehand.AgentExecuteOptions); }); + + it("cookies accepts optional urls parameter", () => { + expectTypeOf().toBeCallableWith(); + expectTypeOf().toBeCallableWith( + "https://example.com", + ); + expectTypeOf().toBeCallableWith([ + "https://example.com", + "https://other.com", + ]); + }); + + it("addCookies accepts array of CookieParam", () => { + const mockCookies: Stagehand.CookieParam[] = [ + { name: "session", value: "abc", url: "https://example.com" }, + ]; + expectTypeOf().toBeCallableWith( + mockCookies, + ); + }); + + it("clearCookies accepts optional filter options", () => { + expectTypeOf().toBeCallableWith(); + expectTypeOf().toBeCallableWith({ + name: "session", + }); + expectTypeOf().toBeCallableWith({ + domain: /\.example\.com/, + }); + }); }); describe("StagehandMetrics", () => { From eb6546a2cec3edde306eb5ca53e531334e07f174 Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 11 Feb 2026 15:37:19 -0800 Subject: [PATCH 03/22] fix lint --- packages/core/tests/cookies.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts index f0c106cd4..019cc842f 100644 --- a/packages/core/tests/cookies.test.ts +++ b/packages/core/tests/cookies.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { filterCookies, normalizeCookieParams, From 93bf6aa766aee21d41db707421a2e00d40a6824f Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 11 Feb 2026 15:45:21 -0800 Subject: [PATCH 04/22] address comments --- packages/core/lib/v3/understudy/context.ts | 24 ++--- packages/core/lib/v3/understudy/cookies.ts | 6 +- packages/core/tests/cookies.test.ts | 104 +++++++++++---------- 3 files changed, 72 insertions(+), 62 deletions(-) diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index df1ae3d44..2b159e728 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -902,25 +902,27 @@ export class V3Context { /** * Clear cookies from the browser context. * - * - Called with no arguments: clears **all** cookies. + * - Called with no arguments: clears **all** cookies atomically via + * `Network.clearBrowserCookies` (single CDP call, no race condition). * - Called with filter options: only cookies matching every supplied criterion - * are removed. Filters accept exact strings or RegExp patterns. - * - * Improvement over Playwright: we delete only the matching cookies via - * `Network.deleteCookies` instead of nuking everything and re-adding the - * non-matching ones. This avoids a race condition where cookies set between - * the get-all and clear-all calls would be lost. + * are removed via targeted `Network.deleteCookies` calls — non-matching + * cookies are never touched (improvement over Playwright's nuke-and-re-add). */ async clearCookies(options?: ClearCookieOptions): Promise { - const current = await this.cookies(); const hasFilter = options?.name !== undefined || options?.domain !== undefined || options?.path !== undefined; - const toDelete = hasFilter - ? current.filter((c) => cookieMatchesFilter(c, options!)) - : current; + if (!hasFilter) { + // Atomic single-call wipe — no race condition, no O(N) roundtrips. + await this.conn.send("Network.clearBrowserCookies"); + return; + } + + // Selective: fetch all, delete only the matching ones. + const current = await this.cookies(); + const toDelete = current.filter((c) => cookieMatchesFilter(c, options!)); for (const c of toDelete) { await this.conn.send("Network.deleteCookies", { diff --git a/packages/core/lib/v3/understudy/cookies.ts b/packages/core/lib/v3/understudy/cookies.ts index dec0fcdf8..d55414aa4 100644 --- a/packages/core/lib/v3/understudy/cookies.ts +++ b/packages/core/lib/v3/understudy/cookies.ts @@ -120,9 +120,11 @@ export function normalizeCookieParams(cookies: CookieParam[]): CookieParam[] { // Browsers silently reject SameSite=None cookies that aren't Secure. // Catch this early with a clear error instead of a silent CDP failure. - if (copy.sameSite === "None" && copy.secure === false) { + // Use !copy.secure to catch both explicit false AND undefined (omitted), + // since CDP defaults secure to false when omitted. + if (copy.sameSite === "None" && !copy.secure) { throw new Error( - `Cookie "${c.name}" has sameSite: "None" but secure: false. ` + + `Cookie "${c.name}" has sameSite: "None" without secure: true. ` + `Browsers require secure: true when sameSite is "None".`, ); } diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts index 019cc842f..672ab789c 100644 --- a/packages/core/tests/cookies.test.ts +++ b/packages/core/tests/cookies.test.ts @@ -304,7 +304,16 @@ describe("normalizeCookieParams", () => { secure: false, }, ]), - ).toThrow(/sameSite: "None" but secure: false/); + ).toThrow(/sameSite: "None" without secure: true/); + }); + + it("throws when sameSite is None and secure is omitted (undefined)", () => { + // CDP defaults secure to false when omitted, so the browser will reject it. + expect(() => + normalizeCookieParams([ + { name: "a", value: "1", domain: "x.com", path: "/", sameSite: "None" }, + ]), + ).toThrow(/sameSite: "None" without secure: true/); }); it("does NOT throw when sameSite is None and secure is true", () => { @@ -322,14 +331,6 @@ describe("normalizeCookieParams", () => { expect(result[0]!.secure).toBe(true); }); - it("does NOT throw when sameSite is None and secure is undefined (not explicitly false)", () => { - // secure is undefined — the browser will decide, we don't block it - const result = normalizeCookieParams([ - { name: "a", value: "1", domain: "x.com", path: "/", sameSite: "None" }, - ]); - expect(result[0]!.sameSite).toBe("None"); - }); - it("derives root path from URL with no trailing path segments", () => { const result = normalizeCookieParams([ { name: "a", value: "1", url: "https://example.com" }, @@ -785,7 +786,7 @@ describe("V3Context cookie methods", () => { secure: false, }, ]), - ).rejects.toThrow(/sameSite: "None" but secure: false/); + ).rejects.toThrow(/sameSite: "None" without secure: true/); }); it("does nothing when passed an empty array", async () => { @@ -880,16 +881,23 @@ describe("V3Context cookie methods", () => { ), ]; - it("deletes ALL cookies when called with no options", async () => { + it("uses atomic Network.clearBrowserCookies when called with no options", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), - "Network.deleteCookies": () => ({}), + "Network.clearBrowserCookies": () => ({}), }); await ctx.clearCookies(); + const clearCalls = getMockConn(ctx).callsFor( + "Network.clearBrowserCookies", + ); + expect(clearCalls).toHaveLength(1); + + // Should NOT have fetched or individually deleted anything + const getCalls = getMockConn(ctx).callsFor("Network.getAllCookies"); + expect(getCalls).toHaveLength(0); const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); - expect(deleteCalls).toHaveLength(3); + expect(deleteCalls).toHaveLength(0); }); it("deletes only cookies matching a name filter", async () => { @@ -969,16 +977,18 @@ describe("V3Context cookie methods", () => { expect(deleteCalls).toHaveLength(1); }); - it("handles empty cookie jar gracefully", async () => { + it("handles empty cookie jar gracefully (atomic clear)", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [] }), - "Network.deleteCookies": () => ({}), + "Network.clearBrowserCookies": () => ({}), }); await ctx.clearCookies(); - const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); - expect(deleteCalls).toHaveLength(0); + // Atomic clear doesn't care whether cookies exist — just fires once + const clearCalls = getMockConn(ctx).callsFor( + "Network.clearBrowserCookies", + ); + expect(clearCalls).toHaveLength(1); }); it("deletes nothing when filter matches no cookies", async () => { @@ -993,13 +1003,14 @@ describe("V3Context cookie methods", () => { expect(deleteCalls).toHaveLength(0); }); - it("sends correct domain and path for each deleted cookie", async () => { + it("sends correct domain and path for each deleted cookie (selective)", async () => { const ctx = makeContext({ "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), "Network.deleteCookies": () => ({}), }); - await ctx.clearCookies(); // delete all + // Use a regex that matches all three to exercise the selective path + await ctx.clearCookies({ name: /.*/ }); const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); expect(deleteCalls).toHaveLength(3); @@ -1067,16 +1078,17 @@ describe("V3Context cookie methods", () => { expect(deleteCalls).toHaveLength(0); }); - it("clearCookies with empty options object deletes all (same as no args)", async () => { + it("clearCookies with empty options object uses atomic clear (same as no args)", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), - "Network.deleteCookies": () => ({}), + "Network.clearBrowserCookies": () => ({}), }); await ctx.clearCookies({}); - const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); - expect(deleteCalls).toHaveLength(3); + const clearCalls = getMockConn(ctx).callsFor( + "Network.clearBrowserCookies", + ); + expect(clearCalls).toHaveLength(1); }); }); @@ -1138,12 +1150,8 @@ describe("V3Context cookie methods", () => { it("clears existing cookies then restores from snapshot", async () => { const callOrder: string[] = []; const ctx = makeContext({ - "Network.getAllCookies": () => { - callOrder.push("getAllCookies"); - return { cookies: [toCdpCookie(makeCookie({ name: "old" }))] }; - }, - "Network.deleteCookies": () => { - callOrder.push("deleteCookies"); + "Network.clearBrowserCookies": () => { + callOrder.push("clearBrowserCookies"); return {}; }, "Network.setCookie": () => { @@ -1158,10 +1166,9 @@ describe("V3Context cookie methods", () => { ], }); - // Should have cleared first, then set + // Should have atomically cleared first, then set expect(callOrder).toEqual([ - "getAllCookies", // from clearCookies - "deleteCookies", // delete the "old" cookie + "clearBrowserCookies", // from clearCookies (atomic) "setCookie", // add the "restored" cookie ]); @@ -1171,7 +1178,7 @@ describe("V3Context cookie methods", () => { it("skips expired cookies from the snapshot", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [] }), + "Network.clearBrowserCookies": () => ({}), "Network.setCookie": () => ({ success: true }), }); @@ -1196,7 +1203,7 @@ describe("V3Context cookie methods", () => { it("handles empty snapshot gracefully", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [] }), + "Network.clearBrowserCookies": () => ({}), "Network.setCookie": () => ({ success: true }), }); @@ -1208,7 +1215,7 @@ describe("V3Context cookie methods", () => { it("keeps session cookies (expires === -1) from snapshot", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [] }), + "Network.clearBrowserCookies": () => ({}), "Network.setCookie": () => ({ success: true }), }); @@ -1223,7 +1230,7 @@ describe("V3Context cookie methods", () => { it("skips all cookies when entire snapshot is expired", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [] }), + "Network.clearBrowserCookies": () => ({}), "Network.setCookie": () => ({ success: true }), }); @@ -1242,26 +1249,26 @@ describe("V3Context cookie methods", () => { it("clears existing cookies even when snapshot is empty", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ - cookies: [toCdpCookie(makeCookie({ name: "existing" }))], - }), - "Network.deleteCookies": () => ({}), + "Network.clearBrowserCookies": () => ({}), "Network.setCookie": () => ({ success: true }), }); await ctx.setStorageState({ cookies: [] }); - const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); - expect(deleteCalls).toHaveLength(1); - expect(deleteCalls[0]!.params).toMatchObject({ name: "existing" }); + // Atomic clear should have fired + const clearCalls = getMockConn(ctx).callsFor( + "Network.clearBrowserCookies", + ); + expect(clearCalls).toHaveLength(1); + // No cookies to restore const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); expect(setCalls).toHaveLength(0); }); it("cookie right at the expiry boundary (now) is treated as expired", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [] }), + "Network.clearBrowserCookies": () => ({}), "Network.setCookie": () => ({ success: true }), }); @@ -1312,8 +1319,7 @@ describe("V3Context cookie methods", () => { // Phase 2: restore into a "fresh" context const setCookieParams: Record[] = []; const ctx2 = makeContext({ - "Network.getAllCookies": () => ({ cookies: [] }), - "Network.deleteCookies": () => ({}), + "Network.clearBrowserCookies": () => ({}), "Network.setCookie": (params) => { setCookieParams.push(params ?? {}); return { success: true }; From 09966715cfee6379437e08047f337ff848a3c646 Mon Sep 17 00:00:00 2001 From: tkattkat Date: Thu, 12 Feb 2026 11:52:29 -0800 Subject: [PATCH 05/22] remove setStorage / move cookies to context --- packages/core/lib/v3/index.ts | 1 - packages/core/lib/v3/understudy/context.ts | 32 --- packages/core/lib/v3/understudy/cookies.ts | 5 - packages/core/lib/v3/v3.ts | 44 ---- packages/core/tests/cookies.test.ts | 247 ------------------ .../tests/public-api/public-types.test.ts | 11 - .../core/tests/public-api/v3-core.test.ts | 34 --- 7 files changed, 374 deletions(-) diff --git a/packages/core/lib/v3/index.ts b/packages/core/lib/v3/index.ts index 3225ec5cd..1735b1965 100644 --- a/packages/core/lib/v3/index.ts +++ b/packages/core/lib/v3/index.ts @@ -62,5 +62,4 @@ export type { Cookie, CookieParam, ClearCookieOptions, - StorageState, } from "./understudy/cookies"; diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index 2b159e728..bed21c1f1 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -16,7 +16,6 @@ import { Cookie, CookieParam, ClearCookieOptions, - StorageState, filterCookies, normalizeCookieParams, cookieMatchesFilter, @@ -932,35 +931,4 @@ export class V3Context { }); } } - - /** - * Snapshot the browser's cookie store for later restoration. - */ - async storageState(): Promise { - return { cookies: await this.cookies() }; - } - - /** - * Restore a previously saved cookie snapshot. - * Clears all existing cookies first then applies the snapshot. - * - * Improvement over Playwright: expired cookies in the snapshot are - * automatically skipped instead of being blindly re added (where the - * browser would reject them anyway). - */ - async setStorageState(state: StorageState): Promise { - await this.clearCookies(); - if (!state.cookies?.length) return; - - const nowSeconds = Date.now() / 1000; - const valid = state.cookies.filter((c) => { - // Session cookies (expires === -1) are always valid. - // Persistent cookies are only valid if they haven't expired. - return c.expires === -1 || c.expires > nowSeconds; - }); - - if (valid.length) { - await this.addCookies(valid); - } - } } diff --git a/packages/core/lib/v3/understudy/cookies.ts b/packages/core/lib/v3/understudy/cookies.ts index d55414aa4..e4caa5e31 100644 --- a/packages/core/lib/v3/understudy/cookies.ts +++ b/packages/core/lib/v3/understudy/cookies.ts @@ -42,11 +42,6 @@ export interface ClearCookieOptions { path?: string | RegExp; } -/** Serialisable snapshot of the browser's cookie store. */ -export interface StorageState { - cookies: Cookie[]; -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index 5721c794a..e0e73ce77 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -79,11 +79,6 @@ import { AgentStreamResult, } from "./types/public/index.js"; import { V3Context } from "./understudy/context.js"; -import type { - Cookie, - CookieParam, - ClearCookieOptions, -} from "./understudy/cookies"; import { Page } from "./understudy/page.js"; import { resolveModel } from "../modelUtils.js"; import { StagehandAPIClient } from "./api.js"; @@ -1391,45 +1386,6 @@ export class V3 { return this.ctx; } - // --------------------------------------------------------------------------- - // Cookie management - // --------------------------------------------------------------------------- - - /** - * Get all browser cookies, optionally filtered by URL(s). - * - * When `urls` is omitted every cookie in the browser context is returned. - * When one or more URLs are supplied only cookies whose domain/path/secure - * attributes match are included. - */ - async cookies(urls?: string | string[]): Promise { - if (!this.ctx) throw new StagehandNotInitializedError("cookies()"); - return this.ctx.cookies(urls); - } - - /** - * Add one or more cookies to the browser context. - * - * Each cookie must specify either a `url` (from which domain/path/secure are - * derived) or an explicit `domain` + `path` pair. - */ - async addCookies(cookies: CookieParam[]): Promise { - if (!this.ctx) throw new StagehandNotInitializedError("addCookies()"); - return this.ctx.addCookies(cookies); - } - - /** - * Clear cookies from the browser context. - * - * - Called with no arguments: clears **all** cookies. - * - Called with filter options: only cookies matching every supplied criterion - * are removed. - */ - async clearCookies(options?: ClearCookieOptions): Promise { - if (!this.ctx) throw new StagehandNotInitializedError("clearCookies()"); - return this.ctx.clearCookies(options); - } - /** Best-effort cleanup of context and launched resources. */ async close(opts?: { force?: boolean }): Promise { // If we're already closing and this isn't a forced close, no-op. diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts index 672ab789c..084f990bc 100644 --- a/packages/core/tests/cookies.test.ts +++ b/packages/core/tests/cookies.test.ts @@ -1091,251 +1091,4 @@ describe("V3Context cookie methods", () => { expect(clearCalls).toHaveLength(1); }); }); - - // ---------- storageState() ---------- - - describe("storageState()", () => { - it("returns a snapshot with all cookies", async () => { - const cdpCookies = [ - toCdpCookie(makeCookie({ name: "a" })), - toCdpCookie(makeCookie({ name: "b" })), - ]; - const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: cdpCookies }), - }); - - const state = await ctx.storageState(); - expect(state.cookies).toHaveLength(2); - expect(state.cookies.map((c) => c.name)).toEqual(["a", "b"]); - }); - - it("returns empty cookies array when browser has none", async () => { - const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [] }), - }); - - const state = await ctx.storageState(); - expect(state.cookies).toEqual([]); - }); - - it("snapshot is JSON-serialisable (round-trip)", async () => { - const cdpCookies = [ - toCdpCookie( - makeCookie({ - name: "ser", - value: "v", - domain: "x.com", - path: "/", - expires: 9999999999, - httpOnly: true, - secure: true, - sameSite: "Strict", - }), - ), - ]; - const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: cdpCookies }), - }); - - const state = await ctx.storageState(); - const json = JSON.stringify(state); - const parsed = JSON.parse(json) as { cookies: Cookie[] }; - expect(parsed.cookies[0]).toEqual(state.cookies[0]); - }); - }); - - // ---------- setStorageState() ---------- - - describe("setStorageState()", () => { - it("clears existing cookies then restores from snapshot", async () => { - const callOrder: string[] = []; - const ctx = makeContext({ - "Network.clearBrowserCookies": () => { - callOrder.push("clearBrowserCookies"); - return {}; - }, - "Network.setCookie": () => { - callOrder.push("setCookie"); - return { success: true }; - }, - }); - - await ctx.setStorageState({ - cookies: [ - makeCookie({ name: "restored", domain: "example.com", path: "/" }), - ], - }); - - // Should have atomically cleared first, then set - expect(callOrder).toEqual([ - "clearBrowserCookies", // from clearCookies (atomic) - "setCookie", // add the "restored" cookie - ]); - - const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); - expect(setCalls[0]!.params).toMatchObject({ name: "restored" }); - }); - - it("skips expired cookies from the snapshot", async () => { - const ctx = makeContext({ - "Network.clearBrowserCookies": () => ({}), - "Network.setCookie": () => ({ success: true }), - }); - - const pastTimestamp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago - const futureTimestamp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now - - await ctx.setStorageState({ - cookies: [ - makeCookie({ name: "expired", expires: pastTimestamp }), - makeCookie({ name: "valid", expires: futureTimestamp }), - makeCookie({ name: "session", expires: -1 }), - ], - }); - - const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); - expect(setCalls).toHaveLength(2); - const setNames = setCalls.map((c) => c.params?.name); - expect(setNames).toContain("valid"); - expect(setNames).toContain("session"); - expect(setNames).not.toContain("expired"); - }); - - it("handles empty snapshot gracefully", async () => { - const ctx = makeContext({ - "Network.clearBrowserCookies": () => ({}), - "Network.setCookie": () => ({ success: true }), - }); - - await ctx.setStorageState({ cookies: [] }); - - const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); - expect(setCalls).toHaveLength(0); - }); - - it("keeps session cookies (expires === -1) from snapshot", async () => { - const ctx = makeContext({ - "Network.clearBrowserCookies": () => ({}), - "Network.setCookie": () => ({ success: true }), - }); - - await ctx.setStorageState({ - cookies: [makeCookie({ name: "sess", expires: -1 })], - }); - - const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); - expect(setCalls).toHaveLength(1); - expect(setCalls[0]!.params).toMatchObject({ name: "sess" }); - }); - - it("skips all cookies when entire snapshot is expired", async () => { - const ctx = makeContext({ - "Network.clearBrowserCookies": () => ({}), - "Network.setCookie": () => ({ success: true }), - }); - - const pastTimestamp = Math.floor(Date.now() / 1000) - 1; - - await ctx.setStorageState({ - cookies: [ - makeCookie({ name: "old1", expires: pastTimestamp }), - makeCookie({ name: "old2", expires: pastTimestamp - 1000 }), - ], - }); - - const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); - expect(setCalls).toHaveLength(0); - }); - - it("clears existing cookies even when snapshot is empty", async () => { - const ctx = makeContext({ - "Network.clearBrowserCookies": () => ({}), - "Network.setCookie": () => ({ success: true }), - }); - - await ctx.setStorageState({ cookies: [] }); - - // Atomic clear should have fired - const clearCalls = getMockConn(ctx).callsFor( - "Network.clearBrowserCookies", - ); - expect(clearCalls).toHaveLength(1); - - // No cookies to restore - const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); - expect(setCalls).toHaveLength(0); - }); - - it("cookie right at the expiry boundary (now) is treated as expired", async () => { - const ctx = makeContext({ - "Network.clearBrowserCookies": () => ({}), - "Network.setCookie": () => ({ success: true }), - }); - - // Exactly now — should be filtered out (not strictly greater) - const nowSeconds = Math.floor(Date.now() / 1000); - - await ctx.setStorageState({ - cookies: [makeCookie({ name: "boundary", expires: nowSeconds })], - }); - - // The expires check is `c.expires > nowSeconds`. Since Date.now() may - // advance by the time setStorageState runs, this cookie is at the edge. - // It should either be filtered or just barely pass — both are acceptable. - // What matters is that clearly-expired cookies don't slip through. - const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); - expect(setCalls.length).toBeLessThanOrEqual(1); - }); - - it("full round-trip: storageState → setStorageState preserves cookies", async () => { - const original = [ - toCdpCookie( - makeCookie({ - name: "auth", - value: "token123", - domain: "app.com", - path: "/", - expires: -1, - }), - ), - toCdpCookie( - makeCookie({ - name: "theme", - value: "dark", - domain: "app.com", - path: "/", - expires: Math.floor(Date.now() / 1000) + 86400, - }), - ), - ]; - - // Phase 1: snapshot - const ctx1 = makeContext({ - "Network.getAllCookies": () => ({ cookies: original }), - }); - const state = await ctx1.storageState(); - expect(state.cookies).toHaveLength(2); - - // Phase 2: restore into a "fresh" context - const setCookieParams: Record[] = []; - const ctx2 = makeContext({ - "Network.clearBrowserCookies": () => ({}), - "Network.setCookie": (params) => { - setCookieParams.push(params ?? {}); - return { success: true }; - }, - }); - await ctx2.setStorageState(state); - - expect(setCookieParams).toHaveLength(2); - expect(setCookieParams[0]).toMatchObject({ - name: "auth", - value: "token123", - }); - expect(setCookieParams[1]).toMatchObject({ - name: "theme", - value: "dark", - }); - }); - }); }); diff --git a/packages/core/tests/public-api/public-types.test.ts b/packages/core/tests/public-api/public-types.test.ts index e80184dac..8f95e7b43 100644 --- a/packages/core/tests/public-api/public-types.test.ts +++ b/packages/core/tests/public-api/public-types.test.ts @@ -101,7 +101,6 @@ type ExpectedExportedTypes = { Cookie: Stagehand.Cookie; CookieParam: Stagehand.CookieParam; ClearCookieOptions: Stagehand.ClearCookieOptions; - StorageState: Stagehand.StorageState; }; describe("Stagehand public API types", () => { @@ -350,14 +349,4 @@ describe("Stagehand public API types", () => { expectTypeOf().toEqualTypeOf(); }); }); - - describe("StorageState", () => { - type ExpectedStorageState = { - cookies: Stagehand.Cookie[]; - }; - - it("matches expected type shape", () => { - expectTypeOf().toEqualTypeOf(); - }); - }); }); diff --git a/packages/core/tests/public-api/v3-core.test.ts b/packages/core/tests/public-api/v3-core.test.ts index 0d8fe1bd3..59b5e0cea 100644 --- a/packages/core/tests/public-api/v3-core.test.ts +++ b/packages/core/tests/public-api/v3-core.test.ts @@ -31,10 +31,6 @@ describe("V3 Core public API types", () => { logger: (logLine: Stagehand.LogLine) => void; isAgentReplayActive: () => boolean; recordAgentReplayStep: (step: unknown) => void; - // Cookie management methods - cookies: (urls?: string | string[]) => Promise; - addCookies: (cookies: Stagehand.CookieParam[]) => Promise; - clearCookies: (options?: Stagehand.ClearCookieOptions) => Promise; }; type StagehandInstance = InstanceType; @@ -75,36 +71,6 @@ describe("V3 Core public API types", () => { page: mockPage, } satisfies Stagehand.AgentExecuteOptions); }); - - it("cookies accepts optional urls parameter", () => { - expectTypeOf().toBeCallableWith(); - expectTypeOf().toBeCallableWith( - "https://example.com", - ); - expectTypeOf().toBeCallableWith([ - "https://example.com", - "https://other.com", - ]); - }); - - it("addCookies accepts array of CookieParam", () => { - const mockCookies: Stagehand.CookieParam[] = [ - { name: "session", value: "abc", url: "https://example.com" }, - ]; - expectTypeOf().toBeCallableWith( - mockCookies, - ); - }); - - it("clearCookies accepts optional filter options", () => { - expectTypeOf().toBeCallableWith(); - expectTypeOf().toBeCallableWith({ - name: "session", - }); - expectTypeOf().toBeCallableWith({ - domain: /\.example\.com/, - }); - }); }); describe("StagehandMetrics", () => { From 1b03baaf316ae075de887838dcf08bd2b1203eab Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Tue, 17 Feb 2026 16:48:47 -0800 Subject: [PATCH 06/22] put types into types/public/context.ts --- packages/core/lib/v3/index.ts | 6 --- packages/core/lib/v3/types/public/context.ts | 34 ++++++++++++++ packages/core/lib/v3/understudy/cookies.ts | 47 +++----------------- 3 files changed, 40 insertions(+), 47 deletions(-) create mode 100644 packages/core/lib/v3/types/public/context.ts diff --git a/packages/core/lib/v3/index.ts b/packages/core/lib/v3/index.ts index 1735b1965..50777ef7c 100644 --- a/packages/core/lib/v3/index.ts +++ b/packages/core/lib/v3/index.ts @@ -57,9 +57,3 @@ export type { } from "./zodCompat.js"; export type { JsonSchema, JsonSchemaProperty } from "../utils.js"; - -export type { - Cookie, - CookieParam, - ClearCookieOptions, -} from "./understudy/cookies"; diff --git a/packages/core/lib/v3/types/public/context.ts b/packages/core/lib/v3/types/public/context.ts new file mode 100644 index 000000000..f5a173436 --- /dev/null +++ b/packages/core/lib/v3/types/public/context.ts @@ -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; +} diff --git a/packages/core/lib/v3/understudy/cookies.ts b/packages/core/lib/v3/understudy/cookies.ts index e4caa5e31..38226ba34 100644 --- a/packages/core/lib/v3/understudy/cookies.ts +++ b/packages/core/lib/v3/understudy/cookies.ts @@ -1,51 +1,16 @@ -// lib/v3/understudy/cookies.ts +import { + Cookie, + CookieParam, + ClearCookieOptions, +} from "../types/public/context"; /** - * Cookie types and helpers for browser cookie management. + * helpers for browser cookie management. * * Mirrors Playwright's cookie API surface, adapted for direct CDP usage * against a single default browser context. */ -/** 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; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - /** * Filter cookies by URL matching (domain, path, secure). * If `urls` is empty every cookie passes. From 2e0e4b3ee40094bd90c97d1cd689cbfd811a9e6b Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Tue, 17 Feb 2026 16:57:14 -0800 Subject: [PATCH 07/22] fix type exports --- packages/core/lib/v3/types/public/index.ts | 1 + packages/core/lib/v3/understudy/context.ts | 8 +++++--- packages/core/tests/cookies.test.ts | 3 +-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/core/lib/v3/types/public/index.ts b/packages/core/lib/v3/types/public/index.ts index f4bfac3ac..b2b368061 100644 --- a/packages/core/lib/v3/types/public/index.ts +++ b/packages/core/lib/v3/types/public/index.ts @@ -9,5 +9,6 @@ export * from "./model.js"; export * from "./options.js"; export * from "./page.js"; export * from "./sdkErrors.js"; +export * from "./context"; export { AISdkClient } from "../../external_clients/aisdk.js"; export { CustomOpenAIClient } from "../../external_clients/customOpenAI.js"; diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index bed21c1f1..5fde62db0 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -13,13 +13,15 @@ import { normalizeInitScriptSource } from "./initScripts.js"; import { TimeoutError, PageNotFoundError } from "../types/public/sdkErrors.js"; import { getEnvTimeoutMs, withTimeout } from "../timeoutConfig.js"; import { - Cookie, - CookieParam, - ClearCookieOptions, filterCookies, normalizeCookieParams, cookieMatchesFilter, } from "./cookies"; +import { + Cookie, + ClearCookieOptions, + CookieParam, +} from "../types/public/context"; type TargetId = string; type SessionId = string; diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts index 084f990bc..e8a72dfa1 100644 --- a/packages/core/tests/cookies.test.ts +++ b/packages/core/tests/cookies.test.ts @@ -3,11 +3,10 @@ import { filterCookies, normalizeCookieParams, cookieMatchesFilter, - type Cookie, - type CookieParam, } from "../lib/v3/understudy/cookies"; import { MockCDPSession } from "./helpers/mockCDPSession"; import type { V3Context } from "../lib/v3/understudy/context"; +import { Cookie, CookieParam } from "../lib/v3/types/public/context"; // --------------------------------------------------------------------------- // Helpers: mock cookie factory From 53493293c0792aca2b4781c324df03a0acc4992c Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Tue, 17 Feb 2026 17:06:23 -0800 Subject: [PATCH 08/22] use custom error types --- .../core/lib/v3/types/public/sdkErrors.ts | 12 ++++++++++++ packages/core/lib/v3/understudy/context.ts | 4 ++-- packages/core/lib/v3/understudy/cookies.ts | 19 ++++++++++++------- .../public-api/public-error-types.test.ts | 2 ++ 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/core/lib/v3/types/public/sdkErrors.ts b/packages/core/lib/v3/types/public/sdkErrors.ts index 88389afc7..5d597551e 100644 --- a/packages/core/lib/v3/types/public/sdkErrors.ts +++ b/packages/core/lib/v3/types/public/sdkErrors.ts @@ -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}`); diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index 5fde62db0..f85e8e18c 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -10,7 +10,7 @@ import type { StagehandAPIClient } from "../api.js"; import { LocalBrowserLaunchOptions } from "../types/public/index.js"; import { InitScriptSource } from "../types/private/index.js"; import { normalizeInitScriptSource } from "./initScripts.js"; -import { TimeoutError, PageNotFoundError } from "../types/public/sdkErrors.js"; +import { TimeoutError, CookieSetError, PageNotFoundError } from "../types/public/sdkErrors.js"; import { getEnvTimeoutMs, withTimeout } from "../timeoutConfig.js"; import { filterCookies, @@ -892,7 +892,7 @@ export class V3Context { }, ); if (!success) { - throw new Error( + throw new CookieSetError( `Failed to set cookie "${c.name}" for domain "${c.domain ?? "(unknown)"}" — ` + `the browser rejected it. Check that the domain, path, and secure/sameSite values are valid.`, ); diff --git a/packages/core/lib/v3/understudy/cookies.ts b/packages/core/lib/v3/understudy/cookies.ts index 38226ba34..c63187f8f 100644 --- a/packages/core/lib/v3/understudy/cookies.ts +++ b/packages/core/lib/v3/understudy/cookies.ts @@ -3,6 +3,7 @@ import { CookieParam, ClearCookieOptions, } from "../types/public/context"; +import { CookieValidationError } from "../types/public/sdkErrors"; /** * helpers for browser cookie management. @@ -43,22 +44,22 @@ export function filterCookies(cookies: Cookie[], urls: string[]): Cookie[] { export function normalizeCookieParams(cookies: CookieParam[]): CookieParam[] { return cookies.map((c) => { if (!c.url && !(c.domain && c.path)) { - throw new Error( + throw new CookieValidationError( `Cookie "${c.name}" must have a url or a domain/path pair`, ); } if (c.url && c.domain) { - throw new Error( + throw new CookieValidationError( `Cookie "${c.name}" should have either url or domain, not both`, ); } if (c.url && c.path) { - throw new Error( + throw new CookieValidationError( `Cookie "${c.name}" should have either url or path, not both`, ); } if (c.expires !== undefined && c.expires < 0 && c.expires !== -1) { - throw new Error( + throw new CookieValidationError( `Cookie "${c.name}" has an invalid expires value; use -1 for session cookies or a positive unix timestamp`, ); } @@ -66,10 +67,14 @@ export function normalizeCookieParams(cookies: CookieParam[]): CookieParam[] { const copy = { ...c }; if (copy.url) { if (copy.url === "about:blank") { - throw new Error(`Blank page cannot have cookie "${c.name}"`); + throw new CookieValidationError( + `Blank page cannot have cookie "${c.name}"`, + ); } if (copy.url.startsWith("data:")) { - throw new Error(`Data URL page cannot have cookie "${c.name}"`); + throw new CookieValidationError( + `Data URL page cannot have cookie "${c.name}"`, + ); } const url = new URL(copy.url); copy.domain = url.hostname; @@ -83,7 +88,7 @@ export function normalizeCookieParams(cookies: CookieParam[]): CookieParam[] { // Use !copy.secure to catch both explicit false AND undefined (omitted), // since CDP defaults secure to false when omitted. if (copy.sameSite === "None" && !copy.secure) { - throw new Error( + throw new CookieValidationError( `Cookie "${c.name}" has sameSite: "None" without secure: true. ` + `Browsers require secure: true when sameSite is "None".`, ); diff --git a/packages/core/tests/public-api/public-error-types.test.ts b/packages/core/tests/public-api/public-error-types.test.ts index 8f2dc170e..94f0080d7 100644 --- a/packages/core/tests/public-api/public-error-types.test.ts +++ b/packages/core/tests/public-api/public-error-types.test.ts @@ -8,6 +8,8 @@ export const publicErrorTypes = { CaptchaTimeoutError: Stagehand.CaptchaTimeoutError, ConnectionTimeoutError: Stagehand.ConnectionTimeoutError, ContentFrameNotFoundError: Stagehand.ContentFrameNotFoundError, + CookieSetError: Stagehand.CookieSetError, + CookieValidationError: Stagehand.CookieValidationError, CreateChatCompletionResponseError: Stagehand.CreateChatCompletionResponseError, CuaModelRequiredError: Stagehand.CuaModelRequiredError, From edb1be49251c04c48c88b71e96eb76765c68c19c Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Tue, 17 Feb 2026 17:49:06 -0800 Subject: [PATCH 09/22] use storage.setcookies instead of network --- packages/core/lib/v3/understudy/context.ts | 72 +++-- packages/core/tests/cookies.test.ts | 293 ++++++++++----------- 2 files changed, 176 insertions(+), 189 deletions(-) diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index f85e8e18c..af02bc4eb 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -850,7 +850,7 @@ export class V3Context { const { cookies } = await this.conn.send<{ cookies: Protocol.Network.Cookie[]; - }>("Network.getAllCookies"); + }>("Storage.getCookies"); const mapped: Cookie[] = cookies.map((c) => ({ name: c.name, @@ -878,23 +878,27 @@ export class V3Context { async addCookies(cookies: CookieParam[]): Promise { const normalized = normalizeCookieParams(cookies); for (const c of normalized) { - const { success } = await this.conn.send<{ success: boolean }>( - "Network.setCookie", - { - name: c.name, - value: c.value, - domain: c.domain, - path: c.path, - expires: c.expires, - httpOnly: c.httpOnly, - secure: c.secure, - sameSite: c.sameSite, - }, - ); - if (!success) { + try { + await this.conn.send("Storage.setCookies", { + cookies: [ + { + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + expires: c.expires, + httpOnly: c.httpOnly, + secure: c.secure, + sameSite: c.sameSite, + }, + ], + }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); throw new CookieSetError( `Failed to set cookie "${c.name}" for domain "${c.domain ?? "(unknown)"}" — ` + - `the browser rejected it. Check that the domain, path, and secure/sameSite values are valid.`, + `the browser rejected it. Check that the domain, path, and secure/sameSite values are valid.` + + (detail ? ` (CDP error: ${detail})` : ""), ); } } @@ -904,10 +908,11 @@ export class V3Context { * Clear cookies from the browser context. * * - Called with no arguments: clears **all** cookies atomically via - * `Network.clearBrowserCookies` (single CDP call, no race condition). - * - Called with filter options: only cookies matching every supplied criterion - * are removed via targeted `Network.deleteCookies` calls — non-matching - * cookies are never touched (improvement over Playwright's nuke-and-re-add). + * `Storage.clearCookies`. + * - Called with filter options: fetches all cookies, clears everything, + * then re-adds only the cookies that do NOT match the filter via + * `Storage.setCookies`. This is necessary on the browser endpoint because + * the Storage domain does not support targeted deletes. */ async clearCookies(options?: ClearCookieOptions): Promise { const hasFilter = @@ -917,19 +922,30 @@ export class V3Context { if (!hasFilter) { // Atomic single-call wipe — no race condition, no O(N) roundtrips. - await this.conn.send("Network.clearBrowserCookies"); + await this.conn.send("Storage.clearCookies"); return; } - // Selective: fetch all, delete only the matching ones. const current = await this.cookies(); - const toDelete = current.filter((c) => cookieMatchesFilter(c, options!)); + const toKeep = current.filter((c) => !cookieMatchesFilter(c, options!)); + + if (toKeep.length === current.length) return; - for (const c of toDelete) { - await this.conn.send("Network.deleteCookies", { - name: c.name, - domain: c.domain, - path: c.path, + // Storage domain doesn't support targeted deletes on the browser endpoint. + // Clear everything, then re-add only the cookies we're keeping. + await this.conn.send("Storage.clearCookies"); + if (toKeep.length) { + await this.conn.send("Storage.setCookies", { + cookies: toKeep.map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + expires: c.expires, + httpOnly: c.httpOnly, + secure: c.secure, + sameSite: c.sameSite, + })), }); } } diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts index e8a72dfa1..e678effc2 100644 --- a/packages/core/tests/cookies.test.ts +++ b/packages/core/tests/cookies.test.ts @@ -26,7 +26,7 @@ function makeCookie(overrides: Partial = {}): Cookie { }; } -/** Convert our Cookie type into the shape CDP's Network.getAllCookies returns. */ +/** Convert our Cookie type into the shape CDP's Storage.getCookies returns. */ function toCdpCookie(c: Cookie) { return { name: c.name, @@ -594,13 +594,13 @@ describe("V3Context cookie methods", () => { // ---------- cookies() ---------- describe("cookies()", () => { - it("returns all cookies from Network.getAllCookies", async () => { + it("returns all cookies from Storage.getCookies", async () => { const cdpCookies = [ toCdpCookie(makeCookie({ name: "a", domain: "example.com" })), toCdpCookie(makeCookie({ name: "b", domain: "other.com" })), ]; const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: cdpCookies }), + "Storage.getCookies": () => ({ cookies: cdpCookies }), }); const result = await ctx.cookies(); @@ -614,7 +614,7 @@ describe("V3Context cookie methods", () => { toCdpCookie(makeCookie({ name: "b", domain: "other.com" })), ]; const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: cdpCookies }), + "Storage.getCookies": () => ({ cookies: cdpCookies }), }); const result = await ctx.cookies("http://example.com/"); @@ -628,7 +628,7 @@ describe("V3Context cookie methods", () => { toCdpCookie(makeCookie({ name: "b", domain: "other.com" })), ]; const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: cdpCookies }), + "Storage.getCookies": () => ({ cookies: cdpCookies }), }); const result = await ctx.cookies(["http://other.com/"]); @@ -642,7 +642,7 @@ describe("V3Context cookie methods", () => { sameSite: undefined as string | undefined, }; const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [cdpCookie] }), + "Storage.getCookies": () => ({ cookies: [cdpCookie] }), }); const result = await ctx.cookies(); @@ -651,7 +651,7 @@ describe("V3Context cookie methods", () => { it("returns empty array when browser has no cookies", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [] }), + "Storage.getCookies": () => ({ cookies: [] }), }); const result = await ctx.cookies(); expect(result).toEqual([]); @@ -671,7 +671,7 @@ describe("V3Context cookie methods", () => { }), ); const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [cdpCookie] }), + "Storage.getCookies": () => ({ cookies: [cdpCookie] }), }); const result = await ctx.cookies(); @@ -690,7 +690,7 @@ describe("V3Context cookie methods", () => { it("strips extra CDP fields (size, priority, etc.) from result", async () => { const cdpCookie = toCdpCookie(makeCookie({ name: "stripped" })); const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [cdpCookie] }), + "Storage.getCookies": () => ({ cookies: [cdpCookie] }), }); const result = await ctx.cookies(); @@ -701,15 +701,15 @@ describe("V3Context cookie methods", () => { expect(keys).not.toContain("sourcePort"); }); - it("calls Network.getAllCookies exactly once per invocation", async () => { + it("calls Storage.getCookies exactly once per invocation", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [] }), + "Storage.getCookies": () => ({ cookies: [] }), }); await ctx.cookies(); await ctx.cookies("http://example.com"); - const calls = getMockConn(ctx).callsFor("Network.getAllCookies"); + const calls = getMockConn(ctx).callsFor("Storage.getCookies"); expect(calls).toHaveLength(2); }); }); @@ -717,9 +717,9 @@ describe("V3Context cookie methods", () => { // ---------- addCookies() ---------- describe("addCookies()", () => { - it("sends Network.setCookie for each cookie", async () => { + it("sends Storage.setCookies for each cookie", async () => { const ctx = makeContext({ - "Network.setCookie": () => ({ success: true }), + "Storage.setCookies": () => ({}), }); await ctx.addCookies([ @@ -727,39 +727,38 @@ describe("V3Context cookie methods", () => { { name: "b", value: "2", domain: "other.com", path: "/" }, ]); - const calls = getMockConn(ctx).callsFor("Network.setCookie"); + const calls = getMockConn(ctx).callsFor("Storage.setCookies"); expect(calls).toHaveLength(2); expect(calls[0]!.params).toMatchObject({ - name: "a", - domain: "example.com", + cookies: [{ name: "a", domain: "example.com" }], }); expect(calls[1]!.params).toMatchObject({ - name: "b", - domain: "other.com", + cookies: [{ name: "b", domain: "other.com" }], }); }); it("derives domain/path/secure from url", async () => { const ctx = makeContext({ - "Network.setCookie": () => ({ success: true }), + "Storage.setCookies": () => ({}), }); await ctx.addCookies([ { name: "a", value: "1", url: "https://example.com/app/page" }, ]); - const calls = getMockConn(ctx).callsFor("Network.setCookie"); + const calls = getMockConn(ctx).callsFor("Storage.setCookies"); expect(calls[0]!.params).toMatchObject({ - name: "a", - domain: "example.com", - path: "/app/", - secure: true, + cookies: [ + { name: "a", domain: "example.com", path: "/app/", secure: true }, + ], }); }); - it("throws when Network.setCookie returns success: false", async () => { + it("throws when Storage.setCookies fails", async () => { const ctx = makeContext({ - "Network.setCookie": () => ({ success: false }), + "Storage.setCookies": () => { + throw new Error("CDP failure"); + }, }); await expect( @@ -771,7 +770,7 @@ describe("V3Context cookie methods", () => { it("throws for sameSite None without secure", async () => { const ctx = makeContext({ - "Network.setCookie": () => ({ success: true }), + "Storage.setCookies": () => ({}), }); await expect( @@ -790,18 +789,18 @@ describe("V3Context cookie methods", () => { it("does nothing when passed an empty array", async () => { const ctx = makeContext({ - "Network.setCookie": () => ({ success: true }), + "Storage.setCookies": () => ({}), }); await ctx.addCookies([]); - const calls = getMockConn(ctx).callsFor("Network.setCookie"); + const calls = getMockConn(ctx).callsFor("Storage.setCookies"); expect(calls).toHaveLength(0); }); it("sends all cookie fields to CDP (including optional ones)", async () => { const ctx = makeContext({ - "Network.setCookie": () => ({ success: true }), + "Storage.setCookies": () => ({}), }); await ctx.addCookies([ @@ -817,26 +816,31 @@ describe("V3Context cookie methods", () => { }, ]); - const calls = getMockConn(ctx).callsFor("Network.setCookie"); + const calls = getMockConn(ctx).callsFor("Storage.setCookies"); expect(calls[0]!.params).toEqual({ - name: "full", - value: "val", - domain: "x.com", - path: "/p", - expires: 9999999999, - httpOnly: true, - secure: true, - sameSite: "Strict", + cookies: [ + { + name: "full", + value: "val", + domain: "x.com", + path: "/p", + expires: 9999999999, + httpOnly: true, + secure: true, + sameSite: "Strict", + }, + ], }); }); it("stops on first failure and does not continue to remaining cookies", async () => { let callCount = 0; const ctx = makeContext({ - "Network.setCookie": () => { + "Storage.setCookies": () => { callCount++; // First succeeds, second fails - return { success: callCount <= 1 }; + if (callCount > 1) throw new Error("CDP failure"); + return {}; }, }); @@ -852,9 +856,11 @@ describe("V3Context cookie methods", () => { expect(callCount).toBe(2); }); - it("error message includes the domain when setCookie fails", async () => { + it("error message includes the domain when setCookies fails", async () => { const ctx = makeContext({ - "Network.setCookie": () => ({ success: false }), + "Storage.setCookies": () => { + throw new Error("CDP failure"); + }, }); await expect( @@ -880,159 +886,124 @@ describe("V3Context cookie methods", () => { ), ]; - it("uses atomic Network.clearBrowserCookies when called with no options", async () => { + it("uses atomic Storage.clearCookies when called with no options", async () => { const ctx = makeContext({ - "Network.clearBrowserCookies": () => ({}), + "Storage.clearCookies": () => ({}), }); await ctx.clearCookies(); - const clearCalls = getMockConn(ctx).callsFor( - "Network.clearBrowserCookies", - ); + const clearCalls = getMockConn(ctx).callsFor("Storage.clearCookies"); expect(clearCalls).toHaveLength(1); - // Should NOT have fetched or individually deleted anything - const getCalls = getMockConn(ctx).callsFor("Network.getAllCookies"); + // Should NOT have fetched or re-set anything + const getCalls = getMockConn(ctx).callsFor("Storage.getCookies"); expect(getCalls).toHaveLength(0); - const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); - expect(deleteCalls).toHaveLength(0); + const setCalls = getMockConn(ctx).callsFor("Storage.setCookies"); + expect(setCalls).toHaveLength(0); }); - it("deletes only cookies matching a name filter", async () => { + it("clears and re-adds only non-matching cookies (name filter)", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), - "Network.deleteCookies": () => ({}), + "Storage.getCookies": () => ({ cookies: [...cdpCookies] }), + "Storage.clearCookies": () => ({}), + "Storage.setCookies": () => ({}), }); await ctx.clearCookies({ name: "_ga" }); - const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); - expect(deleteCalls).toHaveLength(1); - expect(deleteCalls[0]!.params).toMatchObject({ name: "_ga" }); + const clearCalls = getMockConn(ctx).callsFor("Storage.clearCookies"); + expect(clearCalls).toHaveLength(1); + + const setCalls = getMockConn(ctx).callsFor("Storage.setCookies"); + expect(setCalls).toHaveLength(1); + const kept = ( + setCalls[0]!.params?.cookies as Array<{ name: string }> + ).map((c) => c.name); + expect(kept).toEqual(["session", "pref"]); }); - it("deletes only cookies matching a domain filter", async () => { + it("clears and re-adds only non-matching cookies (domain filter)", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), - "Network.deleteCookies": () => ({}), + "Storage.getCookies": () => ({ cookies: [...cdpCookies] }), + "Storage.clearCookies": () => ({}), + "Storage.setCookies": () => ({}), }); await ctx.clearCookies({ domain: "other.com" }); - const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); - expect(deleteCalls).toHaveLength(1); - expect(deleteCalls[0]!.params).toMatchObject({ - name: "pref", - domain: "other.com", - }); + const setCalls = getMockConn(ctx).callsFor("Storage.setCookies"); + const kept = ( + setCalls[0]!.params?.cookies as Array<{ name: string }> + ).map((c) => c.name); + expect(kept).toEqual(["session", "_ga"]); }); - it("deletes cookies matching a regex pattern", async () => { + it("clears and re-adds only non-matching cookies (regex name)", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), - "Network.deleteCookies": () => ({}), + "Storage.getCookies": () => ({ cookies: [...cdpCookies] }), + "Storage.clearCookies": () => ({}), + "Storage.setCookies": () => ({}), }); await ctx.clearCookies({ name: /^_ga/ }); - const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); - expect(deleteCalls).toHaveLength(1); - expect(deleteCalls[0]!.params).toMatchObject({ name: "_ga" }); + const setCalls = getMockConn(ctx).callsFor("Storage.setCookies"); + const kept = ( + setCalls[0]!.params?.cookies as Array<{ name: string }> + ).map((c) => c.name); + expect(kept).toEqual(["session", "pref"]); }); it("applies AND logic across multiple filters", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), - "Network.deleteCookies": () => ({}), + "Storage.getCookies": () => ({ cookies: [...cdpCookies] }), + "Storage.clearCookies": () => ({}), + "Storage.setCookies": () => ({}), }); - // name matches "session" AND domain matches "example.com" await ctx.clearCookies({ name: "session", domain: "example.com" }); - const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); - expect(deleteCalls).toHaveLength(1); - expect(deleteCalls[0]!.params).toMatchObject({ - name: "session", - domain: "example.com", - }); - }); - - it("does not delete non-matching cookies (no nuke-and-re-add)", async () => { - const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), - "Network.deleteCookies": () => ({}), - "Network.setCookie": () => ({ success: true }), - }); - - await ctx.clearCookies({ name: "session" }); - - // Should NOT have called setCookie (no re-add needed) - const setCalls = getMockConn(ctx).callsFor("Network.setCookie"); - expect(setCalls).toHaveLength(0); - - // Should only have deleted the one matching cookie - const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); - expect(deleteCalls).toHaveLength(1); - }); - - it("handles empty cookie jar gracefully (atomic clear)", async () => { - const ctx = makeContext({ - "Network.clearBrowserCookies": () => ({}), - }); - - await ctx.clearCookies(); - - // Atomic clear doesn't care whether cookies exist — just fires once - const clearCalls = getMockConn(ctx).callsFor( - "Network.clearBrowserCookies", - ); - expect(clearCalls).toHaveLength(1); + const setCalls = getMockConn(ctx).callsFor("Storage.setCookies"); + const kept = ( + setCalls[0]!.params?.cookies as Array<{ name: string }> + ).map((c) => c.name); + expect(kept).toEqual(["_ga", "pref"]); }); - it("deletes nothing when filter matches no cookies", async () => { + it("does nothing when filter matches no cookies", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), - "Network.deleteCookies": () => ({}), + "Storage.getCookies": () => ({ cookies: [...cdpCookies] }), + "Storage.clearCookies": () => ({}), + "Storage.setCookies": () => ({}), }); await ctx.clearCookies({ name: "nonexistent" }); - const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); - expect(deleteCalls).toHaveLength(0); + const clearCalls = getMockConn(ctx).callsFor("Storage.clearCookies"); + expect(clearCalls).toHaveLength(0); + const setCalls = getMockConn(ctx).callsFor("Storage.setCookies"); + expect(setCalls).toHaveLength(0); }); - it("sends correct domain and path for each deleted cookie (selective)", async () => { + it("clears without re-adding when filter matches all cookies", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), - "Network.deleteCookies": () => ({}), + "Storage.getCookies": () => ({ cookies: [...cdpCookies] }), + "Storage.clearCookies": () => ({}), + "Storage.setCookies": () => ({}), }); - // Use a regex that matches all three to exercise the selective path await ctx.clearCookies({ name: /.*/ }); - const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); - expect(deleteCalls).toHaveLength(3); - expect(deleteCalls[0]!.params).toMatchObject({ - name: "session", - domain: "example.com", - path: "/", - }); - expect(deleteCalls[1]!.params).toMatchObject({ - name: "_ga", - domain: ".example.com", - path: "/", - }); - expect(deleteCalls[2]!.params).toMatchObject({ - name: "pref", - domain: "other.com", - path: "/settings", - }); + const clearCalls = getMockConn(ctx).callsFor("Storage.clearCookies"); + expect(clearCalls).toHaveLength(1); + const setCalls = getMockConn(ctx).callsFor("Storage.setCookies"); + expect(setCalls).toHaveLength(0); }); it("handles regex that matches multiple cookies", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ + "Storage.getCookies": () => ({ cookies: [ toCdpCookie( makeCookie({ name: "_ga_ABC", domain: "example.com", path: "/" }), @@ -1048,45 +1019,45 @@ describe("V3Context cookie methods", () => { ), ], }), - "Network.deleteCookies": () => ({}), + "Storage.clearCookies": () => ({}), + "Storage.setCookies": () => ({}), }); await ctx.clearCookies({ name: /^_ga/ }); - const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); - expect(deleteCalls).toHaveLength(2); - const deletedNames = deleteCalls.map((c) => c.params?.name); - expect(deletedNames).toContain("_ga_ABC"); - expect(deletedNames).toContain("_ga_DEF"); - expect(deletedNames).not.toContain("_gid"); - expect(deletedNames).not.toContain("session"); + const setCalls = getMockConn(ctx).callsFor("Storage.setCookies"); + const kept = ( + setCalls[0]!.params?.cookies as Array<{ name: string }> + ).map((c) => c.name); + expect(kept).toContain("_gid"); + expect(kept).toContain("session"); + expect(kept).not.toContain("_ga_ABC"); + expect(kept).not.toContain("_ga_DEF"); }); it("regex domain filter combined with path filter", async () => { const ctx = makeContext({ - "Network.getAllCookies": () => ({ cookies: [...cdpCookies] }), - "Network.deleteCookies": () => ({}), + "Storage.getCookies": () => ({ cookies: [...cdpCookies] }), + "Storage.clearCookies": () => ({}), + "Storage.setCookies": () => ({}), }); - // Match domain containing "example" AND path "/settings" - // Only "pref" has path "/settings" but domain is "other.com" — no match - // No cookie has both domain matching /example/ AND path "/settings" await ctx.clearCookies({ domain: /example/, path: "/settings" }); - const deleteCalls = getMockConn(ctx).callsFor("Network.deleteCookies"); - expect(deleteCalls).toHaveLength(0); + const clearCalls = getMockConn(ctx).callsFor("Storage.clearCookies"); + expect(clearCalls).toHaveLength(0); + const setCalls = getMockConn(ctx).callsFor("Storage.setCookies"); + expect(setCalls).toHaveLength(0); }); it("clearCookies with empty options object uses atomic clear (same as no args)", async () => { const ctx = makeContext({ - "Network.clearBrowserCookies": () => ({}), + "Storage.clearCookies": () => ({}), }); await ctx.clearCookies({}); - const clearCalls = getMockConn(ctx).callsFor( - "Network.clearBrowserCookies", - ); + const clearCalls = getMockConn(ctx).callsFor("Storage.clearCookies"); expect(clearCalls).toHaveLength(1); }); }); From f88ccedda3900bf16573606aecbe185ff13bde98 Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Tue, 17 Feb 2026 17:58:55 -0800 Subject: [PATCH 10/22] update JSdoc --- packages/core/lib/v3/understudy/context.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index af02bc4eb..6e4f7d287 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -10,7 +10,11 @@ import type { StagehandAPIClient } from "../api.js"; import { LocalBrowserLaunchOptions } from "../types/public/index.js"; import { InitScriptSource } from "../types/private/index.js"; import { normalizeInitScriptSource } from "./initScripts.js"; -import { TimeoutError, CookieSetError, PageNotFoundError } from "../types/public/sdkErrors.js"; +import { + TimeoutError, + CookieSetError, + PageNotFoundError, +} from "../types/public/sdkErrors.js"; import { getEnvTimeoutMs, withTimeout } from "../timeoutConfig.js"; import { filterCookies, @@ -872,8 +876,7 @@ export class V3Context { * Each cookie must specify either a `url` (from which domain/path/secure are * derived) or an explicit `domain` + `path` pair. * - * Unlike Playwright, we check the CDP success flag for each cookie and throw - * if the browser rejects it (Playwright silently ignores failures). + * We surface CDP errors if the browser rejects a cookie. */ async addCookies(cookies: CookieParam[]): Promise { const normalized = normalizeCookieParams(cookies); @@ -886,7 +889,7 @@ export class V3Context { value: c.value, domain: c.domain, path: c.path, - expires: c.expires, + expires: c.expires === -1 ? undefined : c.expires, httpOnly: c.httpOnly, secure: c.secure, sameSite: c.sameSite, @@ -941,7 +944,7 @@ export class V3Context { value: c.value, domain: c.domain, path: c.path, - expires: c.expires, + expires: c.expires === -1 ? undefined : c.expires, httpOnly: c.httpOnly, secure: c.secure, sameSite: c.sameSite, From c952eeb3c854f0f7c3a01e545a957e8f5e48b4ed Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 18 Feb 2026 16:41:52 -0800 Subject: [PATCH 11/22] add more tests --- packages/core/lib/v3/tests/cookies.spec.ts | 233 +++++++++++++++++++++ packages/core/tests/cookies.test.ts | 104 +++++++++ 2 files changed, 337 insertions(+) create mode 100644 packages/core/lib/v3/tests/cookies.spec.ts diff --git a/packages/core/lib/v3/tests/cookies.spec.ts b/packages/core/lib/v3/tests/cookies.spec.ts new file mode 100644 index 000000000..3676c6b39 --- /dev/null +++ b/packages/core/lib/v3/tests/cookies.spec.ts @@ -0,0 +1,233 @@ +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/sites/example/", + ); + + 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(), + ); + }); +}); diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts index e678effc2..556aecf30 100644 --- a/packages/core/tests/cookies.test.ts +++ b/packages/core/tests/cookies.test.ts @@ -1060,5 +1060,109 @@ describe("V3Context cookie methods", () => { const clearCalls = getMockConn(ctx).callsFor("Storage.clearCookies"); expect(clearCalls).toHaveLength(1); }); + + it("clears and re-adds only non-matching cookies (path filter)", async () => { + const ctx = makeContext({ + "Storage.getCookies": () => ({ cookies: [...cdpCookies] }), + "Storage.clearCookies": () => ({}), + "Storage.setCookies": () => ({}), + }); + + await ctx.clearCookies({ path: "/settings" }); + + const clearCalls = getMockConn(ctx).callsFor("Storage.clearCookies"); + expect(clearCalls).toHaveLength(1); + + const setCalls = getMockConn(ctx).callsFor("Storage.setCookies"); + expect(setCalls).toHaveLength(1); + const kept = ( + setCalls[0]!.params?.cookies as Array<{ name: string }> + ).map((c) => c.name); + expect(kept).toEqual(["session", "_ga"]); + expect(kept).not.toContain("pref"); + }); + + it("throws when Storage.getCookies fails during filtered clear", async () => { + const ctx = makeContext({ + "Storage.getCookies": () => { + throw new Error("CDP getCookies failure"); + }, + "Storage.clearCookies": () => ({}), + "Storage.setCookies": () => ({}), + }); + + await expect(ctx.clearCookies({ name: "session" })).rejects.toThrow( + /CDP getCookies failure/, + ); + + // clearCookies and setCookies should never have been called + const clearCalls = getMockConn(ctx).callsFor("Storage.clearCookies"); + expect(clearCalls).toHaveLength(0); + const setCalls = getMockConn(ctx).callsFor("Storage.setCookies"); + expect(setCalls).toHaveLength(0); + }); + + it("throws when Storage.clearCookies fails during filtered clear", async () => { + const ctx = makeContext({ + "Storage.getCookies": () => ({ cookies: [...cdpCookies] }), + "Storage.clearCookies": () => { + throw new Error("CDP clearCookies failure"); + }, + "Storage.setCookies": () => ({}), + }); + + await expect(ctx.clearCookies({ name: "session" })).rejects.toThrow( + /CDP clearCookies failure/, + ); + + // setCookies should never have been called — cookies are untouched + const setCalls = getMockConn(ctx).callsFor("Storage.setCookies"); + expect(setCalls).toHaveLength(0); + }); + + it("throws when Storage.setCookies fails during re-add, cookies are already wiped", async () => { + const ctx = makeContext({ + "Storage.getCookies": () => ({ cookies: [...cdpCookies] }), + "Storage.clearCookies": () => ({}), + "Storage.setCookies": () => { + throw new Error("CDP setCookies failure"); + }, + }); + + await expect(ctx.clearCookies({ name: "session" })).rejects.toThrow( + /CDP setCookies failure/, + ); + + // clearCookies WAS called — cookies are gone + const clearCalls = getMockConn(ctx).callsFor("Storage.clearCookies"); + expect(clearCalls).toHaveLength(1); + }); + }); + + // ---------- cookies() sameSite edge cases ---------- + + describe("cookies() sameSite mapping", () => { + it("passes through valid sameSite values as-is", async () => { + for (const sameSite of ["Strict", "Lax", "None"] as const) { + const cdpCookie = { ...toCdpCookie(makeCookie()), sameSite }; + const ctx = makeContext({ + "Storage.getCookies": () => ({ cookies: [cdpCookie] }), + }); + const result = await ctx.cookies(); + expect(result[0]!.sameSite).toBe(sameSite); + } + }); + + it("does not normalize lowercase sameSite values from CDP", async () => { + // CDP may return lowercase values; the current implementation casts + // without normalizing, so "none" passes through as-is. + const cdpCookie = { ...toCdpCookie(makeCookie()), sameSite: "none" }; + const ctx = makeContext({ + "Storage.getCookies": () => ({ cookies: [cdpCookie] }), + }); + const result = await ctx.cookies(); + // This documents the current behavior: lowercase is NOT normalized. + expect(result[0]!.sameSite).toBe("none"); + }); }); }); From efc4af1790bdf0246285bda42ff3cad1135025e4 Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 18 Feb 2026 16:50:05 -0800 Subject: [PATCH 12/22] rm redundant comments --- packages/core/lib/v3/understudy/context.ts | 4 ---- packages/core/tests/cookies.test.ts | 24 ---------------------- 2 files changed, 28 deletions(-) diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index 6e4f7d287..9ec2d8e5c 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -838,10 +838,6 @@ export class V3Context { throw new PageNotFoundError("awaitActivePage: no page available"); } - // --------------------------------------------------------------------------- - // Cookie management (browser-context level) - // --------------------------------------------------------------------------- - /** * Get all browser cookies, optionally filtered by URL(s). * diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts index 556aecf30..38b6a0648 100644 --- a/packages/core/tests/cookies.test.ts +++ b/packages/core/tests/cookies.test.ts @@ -8,10 +8,6 @@ import { MockCDPSession } from "./helpers/mockCDPSession"; import type { V3Context } from "../lib/v3/understudy/context"; import { Cookie, CookieParam } from "../lib/v3/types/public/context"; -// --------------------------------------------------------------------------- -// Helpers: mock cookie factory -// --------------------------------------------------------------------------- - function makeCookie(overrides: Partial = {}): Cookie { return { name: "sid", @@ -198,10 +194,6 @@ describe("filterCookies", () => { }); }); -// ============================================================================ -// normalizeCookieParams -// ============================================================================ - describe("normalizeCookieParams", () => { it("passes through cookies with domain+path", () => { const input: CookieParam[] = [ @@ -443,10 +435,6 @@ describe("normalizeCookieParams", () => { }); }); -// ============================================================================ -// cookieMatchesFilter -// ============================================================================ - describe("cookieMatchesFilter", () => { const cookie = makeCookie({ name: "session", @@ -556,10 +544,6 @@ describe("cookieMatchesFilter", () => { }); }); -// ============================================================================ -// V3Context cookie methods (integration with MockCDPSession) -// ============================================================================ - describe("V3Context cookie methods", () => { // We test V3Context methods by constructing a minimal instance with a mock // CDP connection. V3Context.create() requires a real WebSocket, so we build @@ -591,8 +575,6 @@ describe("V3Context cookie methods", () => { return (ctx as unknown as { conn: MockCDPSession }).conn; } - // ---------- cookies() ---------- - describe("cookies()", () => { it("returns all cookies from Storage.getCookies", async () => { const cdpCookies = [ @@ -714,8 +696,6 @@ describe("V3Context cookie methods", () => { }); }); - // ---------- addCookies() ---------- - describe("addCookies()", () => { it("sends Storage.setCookies for each cookie", async () => { const ctx = makeContext({ @@ -871,8 +851,6 @@ describe("V3Context cookie methods", () => { }); }); - // ---------- clearCookies() ---------- - describe("clearCookies()", () => { const cdpCookies = [ toCdpCookie( @@ -1139,8 +1117,6 @@ describe("V3Context cookie methods", () => { }); }); - // ---------- cookies() sameSite edge cases ---------- - describe("cookies() sameSite mapping", () => { it("passes through valid sameSite values as-is", async () => { for (const sameSite of ["Strict", "Lax", "None"] as const) { From 29b6cfedb96ded198cc608fd86180e8c7ba24686 Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 18 Feb 2026 16:54:37 -0800 Subject: [PATCH 13/22] address comment --- packages/core/lib/v3/tests/cookies.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/lib/v3/tests/cookies.spec.ts b/packages/core/lib/v3/tests/cookies.spec.ts index 3676c6b39..f93ad2897 100644 --- a/packages/core/lib/v3/tests/cookies.spec.ts +++ b/packages/core/lib/v3/tests/cookies.spec.ts @@ -150,9 +150,7 @@ test.describe("cookies", () => { ]); // Navigate to a different path on the same domain - await page.goto( - "https://browserbase.github.io/stagehand-eval-sites/sites/example/", - ); + await page.goto("https://browserbase.github.io/stagehand-eval-sites/"); const cookieString = await page.evaluate(() => document.cookie); expect(cookieString).toContain(`${name}=persisted`); From b02309e40832ef6a1c493b8727849ee59d2e44c1 Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 18 Feb 2026 16:57:13 -0800 Subject: [PATCH 14/22] rm more redundant comments --- packages/core/tests/cookies.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts index 38b6a0648..ab4fbaa90 100644 --- a/packages/core/tests/cookies.test.ts +++ b/packages/core/tests/cookies.test.ts @@ -42,10 +42,6 @@ function toCdpCookie(c: Cookie) { }; } -// ============================================================================ -// filterCookies -// ============================================================================ - describe("filterCookies", () => { const cookies: Cookie[] = [ makeCookie({ name: "a", domain: "example.com", path: "/", secure: false }), From a259f52b24affe376d606e93b22a0f12c546bd31 Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 18 Feb 2026 17:01:59 -0800 Subject: [PATCH 15/22] use .js ext --- packages/core/lib/v3/types/public/index.ts | 2 +- packages/core/lib/v3/understudy/context.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/lib/v3/types/public/index.ts b/packages/core/lib/v3/types/public/index.ts index b2b368061..a297598be 100644 --- a/packages/core/lib/v3/types/public/index.ts +++ b/packages/core/lib/v3/types/public/index.ts @@ -9,6 +9,6 @@ export * from "./model.js"; export * from "./options.js"; export * from "./page.js"; export * from "./sdkErrors.js"; -export * from "./context"; +export * from "./context.js"; export { AISdkClient } from "../../external_clients/aisdk.js"; export { CustomOpenAIClient } from "../../external_clients/customOpenAI.js"; diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index 9ec2d8e5c..db5a2689d 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -20,12 +20,12 @@ import { filterCookies, normalizeCookieParams, cookieMatchesFilter, -} from "./cookies"; +} from "./cookies.js"; import { Cookie, ClearCookieOptions, CookieParam, -} from "../types/public/context"; +} from "../types/public/context.js"; type TargetId = string; type SessionId = string; From 15c242232e1d19aed199d504f5c7496c9c559a5f Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 18 Feb 2026 17:23:16 -0800 Subject: [PATCH 16/22] use .js ext in cookies.ts --- packages/core/lib/v3/understudy/cookies.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/lib/v3/understudy/cookies.ts b/packages/core/lib/v3/understudy/cookies.ts index c63187f8f..35246fda3 100644 --- a/packages/core/lib/v3/understudy/cookies.ts +++ b/packages/core/lib/v3/understudy/cookies.ts @@ -2,8 +2,8 @@ import { Cookie, CookieParam, ClearCookieOptions, -} from "../types/public/context"; -import { CookieValidationError } from "../types/public/sdkErrors"; +} from "../types/public/context.js"; +import { CookieValidationError } from "../types/public/sdkErrors.js"; /** * helpers for browser cookie management. From f2beaf0ed60f165e53c16e10e9899cf7bd0738b5 Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 18 Feb 2026 17:45:00 -0800 Subject: [PATCH 17/22] batch cookies sent via cdp --- packages/core/lib/v3/understudy/context.ts | 47 +++++++++++----------- packages/core/tests/cookies.test.ts | 44 +++++--------------- 2 files changed, 34 insertions(+), 57 deletions(-) diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index db5a2689d..52d243e90 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -876,30 +876,29 @@ export class V3Context { */ async addCookies(cookies: CookieParam[]): Promise { const normalized = normalizeCookieParams(cookies); - for (const c of normalized) { - try { - await this.conn.send("Storage.setCookies", { - cookies: [ - { - name: c.name, - value: c.value, - domain: c.domain, - path: c.path, - expires: c.expires === -1 ? undefined : c.expires, - httpOnly: c.httpOnly, - secure: c.secure, - sameSite: c.sameSite, - }, - ], - }); - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - throw new CookieSetError( - `Failed to set cookie "${c.name}" for domain "${c.domain ?? "(unknown)"}" — ` + - `the browser rejected it. Check that the domain, path, and secure/sameSite values are valid.` + - (detail ? ` (CDP error: ${detail})` : ""), - ); - } + if (!normalized.length) return; + + const cdpCookies = normalized.map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + expires: c.expires === -1 ? undefined : c.expires, + httpOnly: c.httpOnly, + secure: c.secure, + sameSite: c.sameSite, + })); + + try { + await this.conn.send("Storage.setCookies", { cookies: cdpCookies }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + const names = normalized.map((c) => `"${c.name}"`).join(", "); + throw new CookieSetError( + `Failed to set cookies [${names}] — ` + + `the browser rejected the batch. Check that the domain, path, and secure/sameSite values are valid.` + + (detail ? ` (CDP error: ${detail})` : ""), + ); } } diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts index ab4fbaa90..d3126a7af 100644 --- a/packages/core/tests/cookies.test.ts +++ b/packages/core/tests/cookies.test.ts @@ -693,7 +693,7 @@ describe("V3Context cookie methods", () => { }); describe("addCookies()", () => { - it("sends Storage.setCookies for each cookie", async () => { + it("sends all cookies in a single Storage.setCookies call", async () => { const ctx = makeContext({ "Storage.setCookies": () => ({}), }); @@ -704,12 +704,12 @@ describe("V3Context cookie methods", () => { ]); const calls = getMockConn(ctx).callsFor("Storage.setCookies"); - expect(calls).toHaveLength(2); + expect(calls).toHaveLength(1); expect(calls[0]!.params).toMatchObject({ - cookies: [{ name: "a", domain: "example.com" }], - }); - expect(calls[1]!.params).toMatchObject({ - cookies: [{ name: "b", domain: "other.com" }], + cookies: [ + { name: "a", domain: "example.com" }, + { name: "b", domain: "other.com" }, + ], }); }); @@ -741,7 +741,7 @@ describe("V3Context cookie methods", () => { ctx.addCookies([ { name: "bad", value: "x", domain: "example.com", path: "/" }, ]), - ).rejects.toThrow(/Failed to set cookie "bad"/); + ).rejects.toThrow(/Failed to set cookies \["bad"\]/); }); it("throws for sameSite None without secure", async () => { @@ -809,30 +809,7 @@ describe("V3Context cookie methods", () => { }); }); - it("stops on first failure and does not continue to remaining cookies", async () => { - let callCount = 0; - const ctx = makeContext({ - "Storage.setCookies": () => { - callCount++; - // First succeeds, second fails - if (callCount > 1) throw new Error("CDP failure"); - return {}; - }, - }); - - await expect( - ctx.addCookies([ - { name: "ok", value: "1", domain: "a.com", path: "/" }, - { name: "fail", value: "2", domain: "b.com", path: "/" }, - { name: "never", value: "3", domain: "c.com", path: "/" }, - ]), - ).rejects.toThrow(/Failed to set cookie "fail"/); - - // "never" should not have been attempted - expect(callCount).toBe(2); - }); - - it("error message includes the domain when setCookies fails", async () => { + it("error message includes all cookie names when batch fails", async () => { const ctx = makeContext({ "Storage.setCookies": () => { throw new Error("CDP failure"); @@ -841,9 +818,10 @@ describe("V3Context cookie methods", () => { await expect( ctx.addCookies([ - { name: "x", value: "1", domain: "specific.com", path: "/" }, + { name: "alpha", value: "1", domain: "a.com", path: "/" }, + { name: "beta", value: "2", domain: "b.com", path: "/" }, ]), - ).rejects.toThrow(/specific\.com/); + ).rejects.toThrow(/Failed to set cookies \["alpha", "beta"\]/); }); }); From 810b7c6f5a3789fdccb1b4c4d3578fa0d3e1cf7e Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 18 Feb 2026 18:18:35 -0800 Subject: [PATCH 18/22] extract shared toCdpCookie helper --- packages/core/lib/v3/understudy/context.ts | 37 +++++++++------------- packages/core/lib/v3/understudy/cookies.ts | 20 ++++++++++++ packages/core/tests/cookies.test.ts | 2 +- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index 52d243e90..e164a7477 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -20,6 +20,7 @@ import { filterCookies, normalizeCookieParams, cookieMatchesFilter, + toCdpCookieParam, } from "./cookies.js"; import { Cookie, @@ -878,16 +879,7 @@ export class V3Context { const normalized = normalizeCookieParams(cookies); if (!normalized.length) return; - const cdpCookies = normalized.map((c) => ({ - name: c.name, - value: c.value, - domain: c.domain, - path: c.path, - expires: c.expires === -1 ? undefined : c.expires, - httpOnly: c.httpOnly, - secure: c.secure, - sameSite: c.sameSite, - })); + const cdpCookies = normalized.map(toCdpCookieParam); try { await this.conn.send("Storage.setCookies", { cookies: cdpCookies }); @@ -933,18 +925,19 @@ export class V3Context { // Clear everything, then re-add only the cookies we're keeping. await this.conn.send("Storage.clearCookies"); if (toKeep.length) { - await this.conn.send("Storage.setCookies", { - cookies: toKeep.map((c) => ({ - name: c.name, - value: c.value, - domain: c.domain, - path: c.path, - expires: c.expires === -1 ? undefined : c.expires, - httpOnly: c.httpOnly, - secure: c.secure, - sameSite: c.sameSite, - })), - }); + try { + await this.conn.send("Storage.setCookies", { + cookies: toKeep.map(toCdpCookieParam), + }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + const names = toKeep.map((c) => `"${c.name}"`).join(", "); + throw new CookieSetError( + `clearCookies: cookies were cleared but failed to re-add the ${toKeep.length} ` + + `non-matching cookie(s) [${names}]. The browser cookie jar is now empty. ` + + (detail ? `(CDP error: ${detail})` : ""), + ); + } } } } diff --git a/packages/core/lib/v3/understudy/cookies.ts b/packages/core/lib/v3/understudy/cookies.ts index 35246fda3..9a25109bf 100644 --- a/packages/core/lib/v3/understudy/cookies.ts +++ b/packages/core/lib/v3/understudy/cookies.ts @@ -98,6 +98,26 @@ export function normalizeCookieParams(cookies: CookieParam[]): CookieParam[] { }); } +/** + * Map a Cookie or CookieParam to the shape CDP's Storage.setCookies expects. + * Session cookies (expires === -1) omit the expires field so CDP treats them + * as session-scoped. + */ +export function toCdpCookieParam( + c: Cookie | CookieParam, +): Record { + return { + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + expires: c.expires === -1 ? undefined : c.expires, + httpOnly: c.httpOnly, + secure: c.secure, + sameSite: c.sameSite, + }; +} + /** * Returns true if a cookie matches all supplied filter criteria. * Undefined filters are treated as "match anything". diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts index d3126a7af..5a7d846ff 100644 --- a/packages/core/tests/cookies.test.ts +++ b/packages/core/tests/cookies.test.ts @@ -1082,7 +1082,7 @@ describe("V3Context cookie methods", () => { }); await expect(ctx.clearCookies({ name: "session" })).rejects.toThrow( - /CDP setCookies failure/, + /cookie jar is now empty/, ); // clearCookies WAS called — cookies are gone From d6fbcf5d1b1814aab426250d704394267ea8fb4e Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 18 Feb 2026 18:34:58 -0800 Subject: [PATCH 19/22] throw cookievalidationerror on malformed URL --- packages/core/lib/v3/understudy/cookies.ts | 19 +++++++++++++++++-- packages/core/tests/cookies.test.ts | 21 +++++++++++++++++---- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/core/lib/v3/understudy/cookies.ts b/packages/core/lib/v3/understudy/cookies.ts index 9a25109bf..19437ab86 100644 --- a/packages/core/lib/v3/understudy/cookies.ts +++ b/packages/core/lib/v3/understudy/cookies.ts @@ -18,7 +18,15 @@ import { CookieValidationError } from "../types/public/sdkErrors.js"; */ export function filterCookies(cookies: Cookie[], urls: string[]): Cookie[] { if (!urls.length) return cookies; - const parsed = urls.map((u) => new URL(u)); + const parsed = urls.map((u) => { + try { + return new URL(u); + } catch { + throw new CookieValidationError( + `Invalid URL passed to cookies(): "${u}"`, + ); + } + }); return cookies.filter((c) => { for (const url of parsed) { let domain = c.domain; @@ -76,7 +84,14 @@ export function normalizeCookieParams(cookies: CookieParam[]): CookieParam[] { `Data URL page cannot have cookie "${c.name}"`, ); } - const url = new URL(copy.url); + let url: URL; + try { + url = new URL(copy.url); + } catch { + throw new CookieValidationError( + `Cookie "${c.name}" has an invalid url: "${copy.url}"`, + ); + } copy.domain = url.hostname; copy.path = url.pathname.substring(0, url.pathname.lastIndexOf("/") + 1); copy.secure = url.protocol === "https:"; diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts index 5a7d846ff..3d78f924e 100644 --- a/packages/core/tests/cookies.test.ts +++ b/packages/core/tests/cookies.test.ts @@ -3,10 +3,10 @@ import { filterCookies, normalizeCookieParams, cookieMatchesFilter, -} from "../lib/v3/understudy/cookies"; -import { MockCDPSession } from "./helpers/mockCDPSession"; -import type { V3Context } from "../lib/v3/understudy/context"; -import { Cookie, CookieParam } from "../lib/v3/types/public/context"; +} from "../lib/v3/understudy/cookies.js"; +import { MockCDPSession } from "./helpers/mockCDPSession.js"; +import type { V3Context } from "../lib/v3/understudy/context.js"; +import { Cookie, CookieParam } from "../lib/v3/types/public/context.js"; function makeCookie(overrides: Partial = {}): Cookie { return { @@ -188,6 +188,13 @@ describe("filterCookies", () => { ); expect(result).toHaveLength(1); }); + + it("throws CookieValidationError for malformed URL", () => { + const c = makeCookie({ name: "a", domain: "example.com" }); + expect(() => filterCookies([c], ["not-a-valid-url"])).toThrow( + /Invalid URL passed to cookies\(\)/, + ); + }); }); describe("normalizeCookieParams", () => { @@ -279,6 +286,12 @@ describe("normalizeCookieParams", () => { ).toThrow(/Data URL/); }); + it("throws CookieValidationError for malformed url", () => { + expect(() => + normalizeCookieParams([{ name: "a", value: "1", url: "not-a-url" }]), + ).toThrow(/Cookie "a" has an invalid url/); + }); + it("throws when sameSite is None but secure is false", () => { expect(() => normalizeCookieParams([ From 7f053c4d60fcb51264a118f5b0bf1776a86ccb6d Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 18 Feb 2026 18:52:41 -0800 Subject: [PATCH 20/22] add cookie path boundary check for url matching --- packages/core/lib/v3/understudy/cookies.ts | 11 ++++++++++- packages/core/tests/cookies.test.ts | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/core/lib/v3/understudy/cookies.ts b/packages/core/lib/v3/understudy/cookies.ts index 19437ab86..43bd638f1 100644 --- a/packages/core/lib/v3/understudy/cookies.ts +++ b/packages/core/lib/v3/understudy/cookies.ts @@ -32,7 +32,16 @@ export function filterCookies(cookies: Cookie[], urls: string[]): Cookie[] { let domain = c.domain; if (!domain.startsWith(".")) domain = "." + domain; if (!("." + url.hostname).endsWith(domain)) continue; - if (!url.pathname.startsWith(c.path)) continue; + // Path must match on a "/" boundary: cookie path "/foo" should match + // "/foo" and "/foo/bar" but NOT "/foobar". + const p = url.pathname; + if ( + !p.startsWith(c.path) || + (c.path.length < p.length && + !c.path.endsWith("/") && + p[c.path.length] !== "/") + ) + continue; if (url.protocol !== "https:" && url.hostname !== "localhost" && c.secure) continue; return true; diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts index 3d78f924e..506430016 100644 --- a/packages/core/tests/cookies.test.ts +++ b/packages/core/tests/cookies.test.ts @@ -161,6 +161,22 @@ describe("filterCookies", () => { expect(result).toHaveLength(0); }); + it("does not match when cookie path is a string prefix but not a path boundary", () => { + // "/foo" should NOT match "/foobar" — only "/foo", "/foo/", "/foo/bar" + const cookie = makeCookie({ + name: "boundary", + domain: "example.com", + path: "/foo", + }); + expect(filterCookies([cookie], ["http://example.com/foobar"])).toHaveLength( + 0, + ); + expect(filterCookies([cookie], ["http://example.com/foo"])).toHaveLength(1); + expect( + filterCookies([cookie], ["http://example.com/foo/bar"]), + ).toHaveLength(1); + }); + it("matches root path against any URL path", () => { const rootCookie = makeCookie({ name: "root", From 8c065651cbc60c31707cf6f2fa13dd62d2b2f63e Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 18 Feb 2026 19:18:24 -0800 Subject: [PATCH 21/22] handle loopback IPs on cookie filtering --- packages/core/lib/v3/understudy/cookies.ts | 7 +++++-- packages/core/tests/cookies.test.ts | 21 ++++++++++++--------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/core/lib/v3/understudy/cookies.ts b/packages/core/lib/v3/understudy/cookies.ts index 43bd638f1..669e88c89 100644 --- a/packages/core/lib/v3/understudy/cookies.ts +++ b/packages/core/lib/v3/understudy/cookies.ts @@ -42,8 +42,11 @@ export function filterCookies(cookies: Cookie[], urls: string[]): Cookie[] { p[c.path.length] !== "/") ) continue; - if (url.protocol !== "https:" && url.hostname !== "localhost" && c.secure) - continue; + const isLoopback = + url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "[::1]"; + if (url.protocol !== "https:" && !isLoopback && c.secure) continue; return true; } return false; diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts index 506430016..0f1dc7163 100644 --- a/packages/core/tests/cookies.test.ts +++ b/packages/core/tests/cookies.test.ts @@ -106,15 +106,18 @@ describe("filterCookies", () => { expect(names).not.toContain("b"); // secure cookie, http URL }); - it("allows secure cookies on localhost regardless of protocol", () => { - const localCookie = makeCookie({ - name: "local", - domain: "localhost", - secure: true, - }); - const result = filterCookies([localCookie], ["http://localhost/"]); - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe("local"); + it("allows secure cookies on loopback addresses regardless of protocol", () => { + const cases = [ + { domain: "localhost", url: "http://localhost/" }, + { domain: "127.0.0.1", url: "http://127.0.0.1/" }, + { domain: "[::1]", url: "http://[::1]/" }, + ]; + for (const { domain, url } of cases) { + const cookie = makeCookie({ name: "loop", domain, secure: true }); + const result = filterCookies([cookie], [url]); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe("loop"); + } }); it("matches against multiple URLs (union)", () => { From e6e3e6b13237aaaad7d00858d33b6ad9ba53d57a Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 18 Feb 2026 19:29:25 -0800 Subject: [PATCH 22/22] bump changeset to minor --- .changeset/fast-buses-kneel.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/fast-buses-kneel.md b/.changeset/fast-buses-kneel.md index 895cca473..115cde6bb 100644 --- a/.changeset/fast-buses-kneel.md +++ b/.changeset/fast-buses-kneel.md @@ -1,5 +1,5 @@ --- -"@browserbasehq/stagehand": patch +"@browserbasehq/stagehand": minor --- -Add native support for setting and getting cookies +Add cookie management APIs: `context.addCookies()`, `context.clearCookies()`, & `context.cookies()`