ReviewOS

stacks/very-happy-dom

BrowserContext owns no state: no cookies, storageState, permissions or geolocation

#1593
Closed glennmichael123 opened this 8 days ago ·
8 days ago

Summary

BrowserContext has six public members — pages, browser, close, waitUntilComplete, abort, newPage — and no state of its own. In Playwright a context is the isolation boundary: it owns cookies, storage, permissions and geolocation, and storageState() is how authenticated sessions get reused across tests.

Here a context is only a grouping of pages. Each page's document carries its own CookieContainer, so cookies set in one page are invisible to a sibling page in the same context.

Reproduction

import { Browser } from 'very-happy-dom'

const context = new Browser().newIncognitoContext()

console.log(typeof context.cookies) // undefined
console.log(typeof context.addCookies) // undefined
console.log(typeof context.clearCookies) // undefined
console.log(typeof context.storageState) // undefined
console.log(typeof context.grantPermissions) // undefined
console.log(typeof context.setGeolocation) // undefined
console.log(typeof context.setOffline) // undefined
console.log(typeof context.setExtraHTTPHeaders) // undefined
console.log(typeof context.addInitScript) // undefined
console.log(typeof context.route) // undefined

// Cookies do not cross pages inside one context:
const p1 = context.newPage()
const p2 = context.newPage()

p1.mainFrame.document.cookie = 'session=abc'

console.log(p1.mainFrame.document.cookie) // session=abc
console.log(p2.mainFrame.document.cookie) // "" — Playwright shares these per context

Confirmed against main at 5fae54d4 with Bun 1.4.3.

Current implementation

BrowserContext stores a Browser reference and a page array. CookieContainer already exists and is a good fit for context-level ownership, but it is currently instantiated per document (VirtualDocument._cookieContainer = new CookieContainer()) and also wrapped by the jsdom CookieJar. Nothing connects it to a context.

Expected behavior

The context owns the jar, and documents read and write through it, so cookies are shared between pages in a context and isolated between contexts.

Cookies:

await context.addCookies([{ name: 'session', value: 'abc', domain: 'example.com', path: '/' }])
const cookies = await context.cookies() // optionally filtered by URL
await context.clearCookies()

Storage state — the highest-value piece, since it is how auth is reused:

const state = await context.storageState() // { cookies: [...], origins: [{ origin, localStorage: [...] }] }

with the matching storageState accepted when creating a context, so a saved session can be restored. That depends on localStorage also being context-scoped per origin rather than per Window.

Emulation, which mostly needs wiring to things that already exist:

MethodNotes
grantPermissions(names, options) / clearPermissions()navigator.permissions.query() already exists and could read this
setGeolocation(coords)the Geolocation API already exists and could read this
setOffline(offline)should flip navigator.onLine and fail requests
setExtraHTTPHeaders(headers)applied to requests from the context
addInitScript(script)run in every new page before its document loads
setDefaultTimeout(ms)default for the waitFor* family

route/unroute on the context is tracked separately in #1594.

Related: #1590 — per-origin localStorage needs frames to have real Window objects first.

8 days ago

Cookies, per-origin storage and storageState are implemented on main in e6e05e4. The emulation knobs are not — see below.

The reported symptom

p1.mainFrame.document.cookie = 'session=abc'
p2.mainFrame.document.cookie  // 'session=abc'  (was '')

The context already had a cookieContainer; nothing was wired to it. Frames now adopt it, so pages in a context behave like tabs in one profile, and another context still sees nothing.

localStorage is shared per origin across the context's pages and re-resolved when a frame navigates to a different origin. sessionStorage is deliberately left per-frame — in a browser it belongs to the tab, not the profile.

storageState round-trips

The auth-reuse case from the report works end to end:

const state = await signedIn.storageState()
// { cookies: [...], origins: [{ origin, localStorage: [{ name, value }] }] }

const fresh = browser.newIncognitoContext()
await fresh.restoreStorageState(state)

An origin-less page (about:blank) and an empty store are left out, since neither is something a restore could apply. close() now drops storage as well as cookies, so a context cannot leak state into later use.

CookieContainer gained getAllCookies(): getCookies matches against a URL and so cannot answer "the whole jar", which is exactly what capturing a state needs.

Deliberately not done, so this stays open

grantPermissions/clearPermissions, setGeolocation, setOffline, setExtraHTTPHeaders, addInitScript and setDefaultTimeout.

They are emulation knobs rather than ownership, and mostly need wiring to APIs that already exist on navigator — independent of the change here. I stopped rather than half-implement six methods in one pass. Happy to pick them up next, or split them into their own issue if you would rather track them separately.

route/unroute on the context is done, in 573adc6 (#1594).

14 tests in test/browser-context-state.test.ts; 10 fail against the previous implementation.

8 days ago

The six remaining knobs are now implemented too, in 41f04a3 — so this issue is fully covered, alongside the ownership half in e6e05e4.

MethodNotes
grantPermissions / clearPermissionsPermissions.query hardcoded granted; it now consults a settable set
setGeolocation(coords | null)Geolocation returned a hardcoded position from two places; both read one configured value
setOfflineflips navigator.onLine and fails a navigation
setExtraHTTPHeadersmerged into navigation requests
addInitScriptevaluated on creation and against each fresh document
setDefaultTimeouton context and page, used by the waitFor* family

Each is held on the context rather than pushed once, so a page created later starts in the same environment as its siblings, and a change reaches pages that already exist. Both directions are tested.

Two decisions worth surfacing

The permissive permissions default is kept until a context intervenes. query answering granted for everything is what feature-detecting code expects, and test/compat.test.ts:298 asserts exactly that. Calling grantPermissions switches to the explicit set, where anything ungranted becomes prompt — which is what makes the unhappy path assertable. Flipping the default instead would have been a silent breaking change.

addInitScript swallows a throwing script. The page still loads, as it would in a browser, rather than the navigation failing because a planted stub was wrong.

One limit, stated rather than implied

setOffline does not block an arbitrary fetch made by page code. Interception is what that would need, and silently swapping fetch there would fight with route() (#1594). What it covers is navigator.onLine and navigation failing — which is what application code actually branches on.

15 tests in test/browser-context-emulation.test.ts. Suite at 1594 passing, lint/typecheck/build clean.

8 days ago

Fixed in e6e05e4 (cookies, storageState and origin storage) and 41f04a3 (permissions, geolocation, offline, extra headers, init scripts, default timeout). Released in v0.1.11, so this is now available to consumers rather than only on main. The scope notes I left earlier on this issue still stand — read those for what was deliberately left out.

Sign in to comment on this issue.