craftdriver 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +111 -0
- package/README.md +157 -45
- package/bin/craftdriver.mjs +26 -0
- package/dist/cli/client.d.ts +17 -0
- package/dist/cli/client.d.ts.map +1 -0
- package/dist/cli/client.js +88 -0
- package/dist/cli/client.js.map +1 -0
- package/dist/cli/daemon.d.ts +15 -0
- package/dist/cli/daemon.d.ts.map +1 -0
- package/dist/cli/daemon.js +145 -0
- package/dist/cli/daemon.js.map +1 -0
- package/dist/cli/defaults.d.ts +10 -0
- package/dist/cli/defaults.d.ts.map +1 -0
- package/dist/cli/defaults.js +42 -0
- package/dist/cli/defaults.js.map +1 -0
- package/dist/cli/dispatcher.d.ts +28 -0
- package/dist/cli/dispatcher.d.ts.map +1 -0
- package/dist/cli/dispatcher.js +319 -0
- package/dist/cli/dispatcher.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +404 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init.d.ts +24 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +192 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/mcp/artifacts.d.ts +43 -0
- package/dist/cli/mcp/artifacts.d.ts.map +1 -0
- package/dist/cli/mcp/artifacts.js +104 -0
- package/dist/cli/mcp/artifacts.js.map +1 -0
- package/dist/cli/mcp/server.d.ts +34 -0
- package/dist/cli/mcp/server.d.ts.map +1 -0
- package/dist/cli/mcp/server.js +214 -0
- package/dist/cli/mcp/server.js.map +1 -0
- package/dist/cli/mcp/tools.d.ts +34 -0
- package/dist/cli/mcp/tools.d.ts.map +1 -0
- package/dist/cli/mcp/tools.js +236 -0
- package/dist/cli/mcp/tools.js.map +1 -0
- package/dist/cli/parseArgs.d.ts +37 -0
- package/dist/cli/parseArgs.d.ts.map +1 -0
- package/dist/cli/parseArgs.js +215 -0
- package/dist/cli/parseArgs.js.map +1 -0
- package/dist/cli/protocol.d.ts +34 -0
- package/dist/cli/protocol.d.ts.map +1 -0
- package/dist/cli/protocol.js +2 -0
- package/dist/cli/protocol.js.map +1 -0
- package/dist/cli/selector.d.ts +29 -0
- package/dist/cli/selector.d.ts.map +1 -0
- package/dist/cli/selector.js +100 -0
- package/dist/cli/selector.js.map +1 -0
- package/dist/cli/snapshot.d.ts +48 -0
- package/dist/cli/snapshot.d.ts.map +1 -0
- package/dist/cli/snapshot.js +179 -0
- package/dist/cli/snapshot.js.map +1 -0
- package/dist/index.d.ts +14 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/a11y.d.ts +81 -0
- package/dist/lib/a11y.d.ts.map +1 -0
- package/dist/lib/a11y.js +171 -0
- package/dist/lib/a11y.js.map +1 -0
- package/dist/lib/bidi/index.d.ts +26 -1
- package/dist/lib/bidi/index.d.ts.map +1 -1
- package/dist/lib/bidi/index.js +49 -0
- package/dist/lib/bidi/index.js.map +1 -1
- package/dist/lib/bidi/logs.d.ts +2 -2
- package/dist/lib/bidi/logs.d.ts.map +1 -1
- package/dist/lib/bidi/logs.js +2 -2
- package/dist/lib/bidi/logs.js.map +1 -1
- package/dist/lib/bidi/network.d.ts +74 -1
- package/dist/lib/bidi/network.d.ts.map +1 -1
- package/dist/lib/bidi/network.js +222 -4
- package/dist/lib/bidi/network.js.map +1 -1
- package/dist/lib/bidi/storage.d.ts +5 -3
- package/dist/lib/bidi/storage.d.ts.map +1 -1
- package/dist/lib/bidi/storage.js +14 -7
- package/dist/lib/bidi/storage.js.map +1 -1
- package/dist/lib/bidi/types.d.ts +27 -6
- package/dist/lib/bidi/types.d.ts.map +1 -1
- package/dist/lib/browser.d.ts +637 -16
- package/dist/lib/browser.d.ts.map +1 -1
- package/dist/lib/browser.js +1428 -102
- package/dist/lib/browser.js.map +1 -1
- package/dist/lib/browserContext.d.ts +486 -0
- package/dist/lib/browserContext.d.ts.map +1 -0
- package/dist/lib/browserContext.js +937 -0
- package/dist/lib/browserContext.js.map +1 -0
- package/dist/lib/builder.d.ts +3 -0
- package/dist/lib/builder.d.ts.map +1 -1
- package/dist/lib/builder.js +31 -4
- package/dist/lib/builder.js.map +1 -1
- package/dist/lib/by.d.ts +18 -2
- package/dist/lib/by.d.ts.map +1 -1
- package/dist/lib/by.js +20 -2
- package/dist/lib/by.js.map +1 -1
- package/dist/lib/chrome.d.ts +13 -1
- package/dist/lib/chrome.d.ts.map +1 -1
- package/dist/lib/chrome.js +13 -17
- package/dist/lib/chrome.js.map +1 -1
- package/dist/lib/clock.d.ts +115 -0
- package/dist/lib/clock.d.ts.map +1 -0
- package/dist/lib/clock.js +407 -0
- package/dist/lib/clock.js.map +1 -0
- package/dist/lib/driver.d.ts +43 -0
- package/dist/lib/driver.d.ts.map +1 -1
- package/dist/lib/driver.js +120 -0
- package/dist/lib/driver.js.map +1 -1
- package/dist/lib/driverManager.d.ts +25 -0
- package/dist/lib/driverManager.d.ts.map +1 -0
- package/dist/lib/driverManager.js +399 -0
- package/dist/lib/driverManager.js.map +1 -0
- package/dist/lib/elementHandle.d.ts +67 -3
- package/dist/lib/elementHandle.d.ts.map +1 -1
- package/dist/lib/elementHandle.js +209 -67
- package/dist/lib/elementHandle.js.map +1 -1
- package/dist/lib/errors.d.ts +71 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +73 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/expect.d.ts +6 -1
- package/dist/lib/expect.d.ts.map +1 -1
- package/dist/lib/expect.js +55 -37
- package/dist/lib/expect.js.map +1 -1
- package/dist/lib/firefox.d.ts +18 -0
- package/dist/lib/firefox.d.ts.map +1 -0
- package/dist/lib/firefox.js +33 -0
- package/dist/lib/firefox.js.map +1 -0
- package/dist/lib/frame.d.ts +57 -0
- package/dist/lib/frame.d.ts.map +1 -0
- package/dist/lib/frame.js +277 -0
- package/dist/lib/frame.js.map +1 -0
- package/dist/lib/keyboard.d.ts +1 -1
- package/dist/lib/keyboard.d.ts.map +1 -1
- package/dist/lib/keyboard.js +1 -1
- package/dist/lib/keyboard.js.map +1 -1
- package/dist/lib/locator.d.ts +71 -2
- package/dist/lib/locator.d.ts.map +1 -1
- package/dist/lib/locator.js +289 -31
- package/dist/lib/locator.js.map +1 -1
- package/dist/lib/mouse.d.ts +1 -1
- package/dist/lib/mouse.d.ts.map +1 -1
- package/dist/lib/mouse.js +1 -1
- package/dist/lib/mouse.js.map +1 -1
- package/dist/lib/page.d.ts +107 -0
- package/dist/lib/page.d.ts.map +1 -0
- package/dist/lib/page.js +388 -0
- package/dist/lib/page.js.map +1 -0
- package/dist/lib/tracing.d.ts +128 -0
- package/dist/lib/tracing.d.ts.map +1 -0
- package/dist/lib/tracing.js +272 -0
- package/dist/lib/tracing.js.map +1 -0
- package/dist/lib/wait.d.ts.map +1 -1
- package/dist/lib/wait.js +14 -1
- package/dist/lib/wait.js.map +1 -1
- package/dist/lib/webelement.d.ts +2 -0
- package/dist/lib/webelement.d.ts.map +1 -1
- package/dist/lib/webelement.js +18 -0
- package/dist/lib/webelement.js.map +1 -1
- package/docs/accessibility.md +177 -0
- package/docs/api-reference.md +71 -0
- package/docs/assertions.md +182 -0
- package/docs/bidi-features.md +462 -0
- package/docs/browser-api.md +673 -0
- package/docs/browser-context.md +522 -0
- package/docs/cli.md +233 -0
- package/docs/clock.md +245 -0
- package/docs/dialogs.md +143 -0
- package/docs/driver-configuration.md +64 -0
- package/docs/element-api.md +219 -0
- package/docs/emulation.md +155 -0
- package/docs/error-codes.md +67 -0
- package/docs/getting-started.md +168 -0
- package/docs/keyboard-mouse.md +250 -0
- package/docs/mcp.md +250 -0
- package/docs/mobile-emulation.md +194 -0
- package/docs/screenshots.md +146 -0
- package/docs/selectors.md +382 -0
- package/docs/session-management.md +229 -0
- package/docs/tracing.md +282 -0
- package/package.json +26 -8
- package/skills/craftdriver/SKILL.md +84 -0
- package/skills/craftdriver/cheatsheet.md +194 -0
- package/skills/craftdriver/cli.md +104 -0
- package/skills/craftdriver/patterns.md +139 -0
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserContext — a WebDriver BiDi user context (an isolated browser
|
|
3
|
+
* profile, equivalent to "open a new incognito window").
|
|
4
|
+
*
|
|
5
|
+
* Each `BrowserContext` owns its own cookies, localStorage, and
|
|
6
|
+
* IndexedDB, isolated from every other context including the default
|
|
7
|
+
* one. Use it to run multi-user scenarios (log in as Alice in one
|
|
8
|
+
* context, as Bob in another) without cookie cross-talk.
|
|
9
|
+
*
|
|
10
|
+
* **BiDi-only.** Maps directly onto BiDi `browser.createUserContext`
|
|
11
|
+
* (§7.2.4.2) and friends. Classic WebDriver has no equivalent and
|
|
12
|
+
* `browser.newContext()` throws there.
|
|
13
|
+
*
|
|
14
|
+
* Obtain via:
|
|
15
|
+
* - `browser.newContext()` — create a fresh isolated profile.
|
|
16
|
+
* - `browser.contexts()` — list all open user contexts.
|
|
17
|
+
* - `browser.defaultContext` — the implicit context the browser
|
|
18
|
+
* started in (id = `'default'`).
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs/promises';
|
|
21
|
+
import { Page } from './page.js';
|
|
22
|
+
export class BrowserContext {
|
|
23
|
+
/** Monotonic counter for synthetic route ids returned by `route()`. */
|
|
24
|
+
static _routeCounter = 0;
|
|
25
|
+
driver;
|
|
26
|
+
conn;
|
|
27
|
+
getDefaultTimeout;
|
|
28
|
+
getNavigationTimeout;
|
|
29
|
+
_id;
|
|
30
|
+
_closed = false;
|
|
31
|
+
_hooks;
|
|
32
|
+
/** Per-context config (baseURL, extraHTTPHeaders). Read by Page for URL resolution. */
|
|
33
|
+
_config;
|
|
34
|
+
/**
|
|
35
|
+
* Preload-script id installed by {@link loadStorageState} to write
|
|
36
|
+
* localStorage entries on first navigation to each captured origin.
|
|
37
|
+
* Tracked so a subsequent call replaces (rather than stacks) the script.
|
|
38
|
+
*/
|
|
39
|
+
_localStoragePreloadId;
|
|
40
|
+
/** Preload-script ids registered via {@link addInitScript}. */
|
|
41
|
+
_initScriptIds = new Set();
|
|
42
|
+
/**
|
|
43
|
+
* Per-route bookkeeping for `route()`. The synthetic id we return to
|
|
44
|
+
* the user maps to its pattern, handler, and the live BiDi intercept
|
|
45
|
+
* ids — one per page that currently belongs to this user context.
|
|
46
|
+
*
|
|
47
|
+
* BiDi `network.addIntercept` does not accept a user-context filter,
|
|
48
|
+
* so we register a separate intercept per page (with `contexts: [pageId]`)
|
|
49
|
+
* and add/remove them as pages open and close.
|
|
50
|
+
*/
|
|
51
|
+
_routes = new Map();
|
|
52
|
+
/** Top-level browsing-context (page) ids currently inside this user context. */
|
|
53
|
+
_pageIds = new Set();
|
|
54
|
+
/**
|
|
55
|
+
* Page ids we've already fired `'page'` listeners for. Tracked
|
|
56
|
+
* separately from `_pageIds` so that the listener fires exactly once
|
|
57
|
+
* regardless of whether `newPage()` populates `_pageIds` before or
|
|
58
|
+
* after the `browsingContext.contextCreated` event arrives.
|
|
59
|
+
*/
|
|
60
|
+
_notifiedPageIds = new Set();
|
|
61
|
+
/**
|
|
62
|
+
* The in-flight promise from the most recent call to {@link _ensurePageTracking}.
|
|
63
|
+
* Doubles as the "is tracking armed?" flag — truthy after the first call,
|
|
64
|
+
* cleared in {@link close}. Used to await readiness when `on('page')` is
|
|
65
|
+
* followed immediately by `newPage()` — Firefox fires `contextCreated`
|
|
66
|
+
* before `session.subscribe` round-trips, so we must defer page creation
|
|
67
|
+
* until the listener is attached.
|
|
68
|
+
*/
|
|
69
|
+
_trackingPromise;
|
|
70
|
+
/** Unsubscribe handles for the contextCreated/Destroyed listeners. */
|
|
71
|
+
_trackingOffs = [];
|
|
72
|
+
_pageListeners = new Set();
|
|
73
|
+
_closeListeners = new Set();
|
|
74
|
+
constructor(driver, conn, userContextId, getDefaultTimeout, getNavigationTimeout, hooks, config = {}) {
|
|
75
|
+
this.driver = driver;
|
|
76
|
+
this.conn = conn;
|
|
77
|
+
this._id = userContextId;
|
|
78
|
+
this.getDefaultTimeout = getDefaultTimeout;
|
|
79
|
+
this.getNavigationTimeout = getNavigationTimeout;
|
|
80
|
+
this._hooks = hooks;
|
|
81
|
+
this._config = { ...config };
|
|
82
|
+
}
|
|
83
|
+
/** The BiDi user-context id. The implicit default context has id `'default'`. */
|
|
84
|
+
get id() {
|
|
85
|
+
return this._id;
|
|
86
|
+
}
|
|
87
|
+
/** True after `close()` has removed the context. */
|
|
88
|
+
get isClosed() {
|
|
89
|
+
return this._closed;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Base URL used to resolve relative paths in `page.navigateTo()`.
|
|
93
|
+
* Configured via `browser.newContext({ baseURL })`. Read by {@link Page}.
|
|
94
|
+
*/
|
|
95
|
+
get baseURL() {
|
|
96
|
+
return this._config.baseURL;
|
|
97
|
+
}
|
|
98
|
+
assertOpen() {
|
|
99
|
+
if (this._closed) {
|
|
100
|
+
throw new Error(`BrowserContext "${this._id}" is closed; create a new one with browser.newContext().`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** BiDi partition descriptor for storage operations scoped to this user context. */
|
|
104
|
+
partition() {
|
|
105
|
+
return { type: 'storageKey', userContext: this._id };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Open a new top-level browsing context (a tab or a window) inside this
|
|
109
|
+
* user context. Maps to BiDi `browsingContext.create` with the
|
|
110
|
+
* `userContext` parameter bound to this id.
|
|
111
|
+
*/
|
|
112
|
+
async newPage(opts) {
|
|
113
|
+
this.assertOpen();
|
|
114
|
+
// If any code path has armed page tracking (typically `on('page', …)`),
|
|
115
|
+
// wait for the subscription to be in place before we trigger
|
|
116
|
+
// `contextCreated` — Firefox emits the event before `session.subscribe`
|
|
117
|
+
// round-trips, and we must not lose it.
|
|
118
|
+
if (this._trackingPromise)
|
|
119
|
+
await this._trackingPromise;
|
|
120
|
+
const created = await this.conn.send('browsingContext.create', {
|
|
121
|
+
type: opts?.type ?? 'tab',
|
|
122
|
+
userContext: this._id,
|
|
123
|
+
});
|
|
124
|
+
const page = new Page(this.driver, created.context, this.getDefaultTimeout, this.conn, this);
|
|
125
|
+
// Track and apply extra headers before navigation so the first request carries them.
|
|
126
|
+
this._pageIds.add(created.context);
|
|
127
|
+
await this._applyExtraHeadersTo([created.context]);
|
|
128
|
+
// Register any existing routes on this new page before navigation.
|
|
129
|
+
for (const sid of this._routes.keys()) {
|
|
130
|
+
await this._registerRouteOnPage(sid, created.context).catch(() => { });
|
|
131
|
+
}
|
|
132
|
+
if (opts?.url) {
|
|
133
|
+
await page.navigateTo(opts.url);
|
|
134
|
+
}
|
|
135
|
+
return page;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Return all open top-level pages that belong to this user context.
|
|
139
|
+
*/
|
|
140
|
+
async pages() {
|
|
141
|
+
this.assertOpen();
|
|
142
|
+
const tree = await this.conn.send('browsingContext.getTree', {});
|
|
143
|
+
return (tree.contexts ?? [])
|
|
144
|
+
.filter((c) => c.userContext === this._id && !c.parent)
|
|
145
|
+
.map((c) => new Page(this.driver, c.context, this.getDefaultTimeout, this.conn, this));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Run `action` and resolve to the next new top-level page that opens
|
|
149
|
+
* **inside this user context**. Useful when a click in one of this
|
|
150
|
+
* context's pages spawns a popup.
|
|
151
|
+
*/
|
|
152
|
+
async waitForPage(action, opts) {
|
|
153
|
+
this.assertOpen();
|
|
154
|
+
const timeout = opts?.timeout ?? this.getNavigationTimeout();
|
|
155
|
+
// Make sure any prior `on('page')` subscription is fully installed before
|
|
156
|
+
// we run `action()` — otherwise a fast popup could fire before we listen.
|
|
157
|
+
if (this._trackingPromise)
|
|
158
|
+
await this._trackingPromise;
|
|
159
|
+
await this.conn.subscribe(['browsingContext.contextCreated']).catch(() => { });
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
const timer = setTimeout(() => {
|
|
162
|
+
off();
|
|
163
|
+
reject(new Error(`waitForPage() timed out after ${timeout}ms`));
|
|
164
|
+
}, timeout);
|
|
165
|
+
const off = this.conn.on('browsingContext.contextCreated', (params) => {
|
|
166
|
+
if (params.parent)
|
|
167
|
+
return; // nested frames
|
|
168
|
+
if (params.userContext !== this._id)
|
|
169
|
+
return;
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
off();
|
|
172
|
+
resolve(new Page(this.driver, params.context, this.getDefaultTimeout, this.conn, this));
|
|
173
|
+
});
|
|
174
|
+
action().catch((err) => {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
off();
|
|
177
|
+
reject(err);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// ── Cookies ──────────────────────────────────────────────────────────────
|
|
182
|
+
/**
|
|
183
|
+
* Return cookies scoped to this user context.
|
|
184
|
+
*
|
|
185
|
+
* Optionally filter by URL(s): only cookies whose `domain`/`path` would
|
|
186
|
+
* be sent on a request to that URL are returned. Mirrors Playwright's
|
|
187
|
+
* `BrowserContext.cookies(urls)` shape.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```ts
|
|
191
|
+
* const session = await alice.cookies(['https://app.example.com']);
|
|
192
|
+
* expect(session.find(c => c.name === 'sid')).toBeDefined();
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
async cookies(urls) {
|
|
196
|
+
this.assertOpen();
|
|
197
|
+
const result = await this.conn.send('storage.getCookies', {
|
|
198
|
+
partition: this.partition(),
|
|
199
|
+
});
|
|
200
|
+
const all = (result.cookies ?? []).map(normalizeCookie);
|
|
201
|
+
if (urls === undefined)
|
|
202
|
+
return all;
|
|
203
|
+
const urlList = Array.isArray(urls) ? urls : [urls];
|
|
204
|
+
const parsed = urlList.map((u) => new URL(u));
|
|
205
|
+
return all.filter((c) => parsed.some((u) => cookieMatchesUrl(c, u)));
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Add cookies to this user context. The `domain` field is required —
|
|
209
|
+
* BiDi rejects host-less cookies. Use `expiry` (UNIX seconds) or omit
|
|
210
|
+
* for a session cookie.
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* ```ts
|
|
214
|
+
* // Pre-seed an authenticated session before the first navigation.
|
|
215
|
+
* await alice.addCookies([
|
|
216
|
+
* { name: 'sid', value: 'eyJhbGciOi…', domain: 'app.example.com', path: '/', secure: true, sameSite: 'lax' },
|
|
217
|
+
* ]);
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
async addCookies(cookies) {
|
|
221
|
+
this.assertOpen();
|
|
222
|
+
for (const c of cookies) {
|
|
223
|
+
// BiDi rejects `sameSite: 'none'` without `secure: true`. Be loud
|
|
224
|
+
// instead of silently rewriting the cookie.
|
|
225
|
+
if (c.sameSite === 'none' && !c.secure) {
|
|
226
|
+
throw new Error(`addCookies: cookie "${c.name}" has sameSite "none" but secure is false. ` +
|
|
227
|
+
`Set secure: true or change sameSite to 'lax'/'strict'.`);
|
|
228
|
+
}
|
|
229
|
+
const partial = {
|
|
230
|
+
name: c.name,
|
|
231
|
+
value: typeof c.value === 'string' ? { type: 'string', value: c.value } : c.value,
|
|
232
|
+
domain: c.domain,
|
|
233
|
+
path: c.path,
|
|
234
|
+
httpOnly: c.httpOnly,
|
|
235
|
+
secure: c.secure,
|
|
236
|
+
sameSite: c.sameSite,
|
|
237
|
+
expiry: c.expiry,
|
|
238
|
+
};
|
|
239
|
+
await this.conn.send('storage.setCookie', {
|
|
240
|
+
cookie: partial,
|
|
241
|
+
partition: this.partition(),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Remove cookies from this user context.
|
|
247
|
+
*
|
|
248
|
+
* - No filter: clears every cookie in this context.
|
|
249
|
+
* - Filter fields are AND-combined: a cookie must match every provided
|
|
250
|
+
* field to be removed.
|
|
251
|
+
*
|
|
252
|
+
* @example
|
|
253
|
+
* ```ts
|
|
254
|
+
* await alice.clearCookies({ name: 'sid' }); // sign Alice out
|
|
255
|
+
* await alice.clearCookies({ domain: 'tracker.io' }); // wipe a single host
|
|
256
|
+
* await alice.clearCookies(); // full reset
|
|
257
|
+
* ```
|
|
258
|
+
*/
|
|
259
|
+
async clearCookies(filter) {
|
|
260
|
+
this.assertOpen();
|
|
261
|
+
await this.conn.send('storage.deleteCookies', {
|
|
262
|
+
filter: filter ?? {},
|
|
263
|
+
partition: this.partition(),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// ── Storage state (Playwright-shape JSON) ────────────────────────────────
|
|
267
|
+
/**
|
|
268
|
+
* Snapshot cookies + localStorage for this context.
|
|
269
|
+
*
|
|
270
|
+
* Returns `{ cookies, localStorage }` — a superset of Playwright's
|
|
271
|
+
* `storageState()` JSON shape, kept close enough that dumped files
|
|
272
|
+
* are interchangeable on a best-effort basis.
|
|
273
|
+
*
|
|
274
|
+
* **Limitation.** localStorage can only be read for origins that a page
|
|
275
|
+
* in this context is currently visiting — BiDi has no out-of-band
|
|
276
|
+
* storage read API. Open one page per origin you want captured before
|
|
277
|
+
* snapshotting.
|
|
278
|
+
*/
|
|
279
|
+
async storageState(opts) {
|
|
280
|
+
this.assertOpen();
|
|
281
|
+
const state = {};
|
|
282
|
+
if (opts?.includeCookies !== false) {
|
|
283
|
+
state.cookies = await this.cookies();
|
|
284
|
+
}
|
|
285
|
+
if (opts?.includeLocalStorage !== false) {
|
|
286
|
+
const local = await this.collectLocalStorage();
|
|
287
|
+
if (Object.keys(local).length > 0) {
|
|
288
|
+
state.localStorage = local;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return state;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Snapshot the context's state and write it to `path` as JSON.
|
|
295
|
+
* Returns the snapshot for inspection.
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* ```ts
|
|
299
|
+
* // tests/setup/login.ts — runs once, before the test suite.
|
|
300
|
+
* const alice = await browser.newContext();
|
|
301
|
+
* const page = await alice.newPage({ url: 'https://app.example.com/login' });
|
|
302
|
+
* await loginAs(page, 'alice');
|
|
303
|
+
* await alice.saveStorageState('auth/alice.json');
|
|
304
|
+
* await alice.close();
|
|
305
|
+
* ```
|
|
306
|
+
*/
|
|
307
|
+
async saveStorageState(path, opts) {
|
|
308
|
+
const state = await this.storageState(opts);
|
|
309
|
+
await fs.writeFile(path, JSON.stringify(state, null, 2), 'utf-8');
|
|
310
|
+
return state;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Apply a previously-captured snapshot to this context.
|
|
314
|
+
*
|
|
315
|
+
* - Cookies are written immediately.
|
|
316
|
+
* - localStorage entries are installed via a preload script that runs
|
|
317
|
+
* on first navigation to each origin, before any page script. This
|
|
318
|
+
* matches the way Playwright restores localStorage and survives full
|
|
319
|
+
* reloads.
|
|
320
|
+
*
|
|
321
|
+
* Calling `loadStorageState` again replaces the previous preload
|
|
322
|
+
* script; it does not stack.
|
|
323
|
+
*
|
|
324
|
+
* @param source A `SessionState` object, or a path to a JSON file
|
|
325
|
+
* produced by {@link saveStorageState}.
|
|
326
|
+
*/
|
|
327
|
+
async loadStorageState(source) {
|
|
328
|
+
this.assertOpen();
|
|
329
|
+
const state = typeof source === 'string'
|
|
330
|
+
? JSON.parse(await fs.readFile(source, 'utf-8'))
|
|
331
|
+
: source;
|
|
332
|
+
if (state.cookies?.length) {
|
|
333
|
+
// Sanitize: a snapshot may contain cookies with sameSite:'none' and
|
|
334
|
+
// secure:false because some engines report that combo for cookies
|
|
335
|
+
// originally set via document.cookie. BiDi rejects it. Drop the
|
|
336
|
+
// sameSite field on those so the engine picks its default.
|
|
337
|
+
const sanitized = state.cookies.map((c) => {
|
|
338
|
+
if (c.sameSite === 'none' && !c.secure) {
|
|
339
|
+
const { sameSite: _drop, ...rest } = c;
|
|
340
|
+
return rest;
|
|
341
|
+
}
|
|
342
|
+
return c;
|
|
343
|
+
});
|
|
344
|
+
await this.addCookies(sanitized);
|
|
345
|
+
}
|
|
346
|
+
// Replace any previously-installed localStorage preload.
|
|
347
|
+
if (this._localStoragePreloadId) {
|
|
348
|
+
await this.conn
|
|
349
|
+
.send('script.removePreloadScript', { script: this._localStoragePreloadId })
|
|
350
|
+
.catch(() => { });
|
|
351
|
+
this._localStoragePreloadId = undefined;
|
|
352
|
+
}
|
|
353
|
+
if (state.localStorage && Object.keys(state.localStorage).length > 0) {
|
|
354
|
+
this._localStoragePreloadId = await this.installLocalStoragePreload(state.localStorage);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Remove this user context. All of its pages are closed and any
|
|
359
|
+
* subsequent operation on this `BrowserContext` instance throws.
|
|
360
|
+
* Fires the `'close'` event before tearing down.
|
|
361
|
+
*
|
|
362
|
+
* The default context (`id === 'default'`) cannot be removed.
|
|
363
|
+
*/
|
|
364
|
+
async close() {
|
|
365
|
+
if (this._closed)
|
|
366
|
+
return;
|
|
367
|
+
if (this._id === 'default') {
|
|
368
|
+
throw new Error('Cannot close the default BrowserContext. ' +
|
|
369
|
+
'Quit the browser instead with browser.quit().');
|
|
370
|
+
}
|
|
371
|
+
// Best-effort cleanup of init scripts and routes before the context
|
|
372
|
+
// goes away — keeps the browser-level interceptor map from leaking.
|
|
373
|
+
for (const id of this._initScriptIds) {
|
|
374
|
+
await this.conn.send('script.removePreloadScript', { script: id }).catch(() => { });
|
|
375
|
+
}
|
|
376
|
+
this._initScriptIds.clear();
|
|
377
|
+
if (this._localStoragePreloadId) {
|
|
378
|
+
await this.conn
|
|
379
|
+
.send('script.removePreloadScript', { script: this._localStoragePreloadId })
|
|
380
|
+
.catch(() => { });
|
|
381
|
+
this._localStoragePreloadId = undefined;
|
|
382
|
+
}
|
|
383
|
+
if (this._hooks && this._routes.size > 0) {
|
|
384
|
+
const net = this._hooks.getNetwork();
|
|
385
|
+
for (const entry of this._routes.values()) {
|
|
386
|
+
for (const realId of entry.perPage.values()) {
|
|
387
|
+
if (!realId)
|
|
388
|
+
continue;
|
|
389
|
+
await net.removeIntercept(realId).catch(() => { });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
this._routes.clear();
|
|
394
|
+
for (const off of this._trackingOffs)
|
|
395
|
+
off();
|
|
396
|
+
this._trackingOffs = [];
|
|
397
|
+
this._trackingPromise = undefined;
|
|
398
|
+
await this.conn.send('browser.removeUserContext', { userContext: this._id });
|
|
399
|
+
this._closed = true;
|
|
400
|
+
// Fire close listeners last, after the context is fully gone.
|
|
401
|
+
for (const fn of this._closeListeners) {
|
|
402
|
+
try {
|
|
403
|
+
fn();
|
|
404
|
+
}
|
|
405
|
+
catch { /* listener errors must not break close() */ }
|
|
406
|
+
}
|
|
407
|
+
this._closeListeners.clear();
|
|
408
|
+
this._pageListeners.clear();
|
|
409
|
+
}
|
|
410
|
+
on(event, listener) {
|
|
411
|
+
if (event === 'close') {
|
|
412
|
+
this._closeListeners.add(listener);
|
|
413
|
+
return () => this._closeListeners.delete(listener);
|
|
414
|
+
}
|
|
415
|
+
this._pageListeners.add(listener);
|
|
416
|
+
// Page tracking is what powers the 'page' event AND `route` membership
|
|
417
|
+
// filtering. Start it lazily on first listener.
|
|
418
|
+
this._ensurePageTracking().catch(() => { });
|
|
419
|
+
return () => this._pageListeners.delete(listener);
|
|
420
|
+
}
|
|
421
|
+
off(event, listener) {
|
|
422
|
+
if (event === 'close')
|
|
423
|
+
this._closeListeners.delete(listener);
|
|
424
|
+
else
|
|
425
|
+
this._pageListeners.delete(listener);
|
|
426
|
+
}
|
|
427
|
+
// ── Init scripts ─────────────────────────────────────────────────────────
|
|
428
|
+
/**
|
|
429
|
+
* Register a script that runs in every page of this context, **before
|
|
430
|
+
* any page script**, on every navigation including popups. Scoped to
|
|
431
|
+
* this user context via BiDi `script.addPreloadScript`'s `userContexts`
|
|
432
|
+
* parameter.
|
|
433
|
+
*
|
|
434
|
+
* Use it for the "every page in this profile gets this hook" patterns:
|
|
435
|
+
* feature flags, fake clocks, auth shims, deterministic Math.random.
|
|
436
|
+
*
|
|
437
|
+
* @example
|
|
438
|
+
* ```ts
|
|
439
|
+
* // Mark every page as running under E2E so the app can skip
|
|
440
|
+
* // animations and disable analytics — no per-page wiring needed.
|
|
441
|
+
* await ctx.addInitScript(() => {
|
|
442
|
+
* window.__E2E__ = true;
|
|
443
|
+
* });
|
|
444
|
+
* ```
|
|
445
|
+
*/
|
|
446
|
+
async addInitScript(script) {
|
|
447
|
+
this.assertOpen();
|
|
448
|
+
const fnDecl = typeof script === 'function' ? script.toString() : `() => { ${script} }`;
|
|
449
|
+
const result = await this.conn.send('script.addPreloadScript', {
|
|
450
|
+
functionDeclaration: fnDecl,
|
|
451
|
+
userContexts: [this._id],
|
|
452
|
+
});
|
|
453
|
+
this._initScriptIds.add(result.script);
|
|
454
|
+
return {
|
|
455
|
+
id: result.script,
|
|
456
|
+
remove: async () => {
|
|
457
|
+
if (!this._initScriptIds.has(result.script))
|
|
458
|
+
return;
|
|
459
|
+
this._initScriptIds.delete(result.script);
|
|
460
|
+
await this.conn
|
|
461
|
+
.send('script.removePreloadScript', { script: result.script })
|
|
462
|
+
.catch(() => { });
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Remove an init script by its handle id. Alternative to calling
|
|
468
|
+
* `handle.remove()` on the value returned by {@link addInitScript}.
|
|
469
|
+
*/
|
|
470
|
+
async removeInitScript(id) {
|
|
471
|
+
if (!this._initScriptIds.has(id))
|
|
472
|
+
return;
|
|
473
|
+
this._initScriptIds.delete(id);
|
|
474
|
+
await this.conn.send('script.removePreloadScript', { script: id }).catch(() => { });
|
|
475
|
+
}
|
|
476
|
+
// ── Routing ──────────────────────────────────────────────────────────────
|
|
477
|
+
/**
|
|
478
|
+
* Intercept network requests originating from any page in this
|
|
479
|
+
* context. The handler runs for every matching request and decides
|
|
480
|
+
* how to answer it (`fulfill` / `continue` / `abort`).
|
|
481
|
+
*
|
|
482
|
+
* Routes are registered as a separate BiDi `network.addIntercept`
|
|
483
|
+
* per page, scoped to that page's browsing-context id. Pages opened
|
|
484
|
+
* later inherit every route automatically; closed pages have their
|
|
485
|
+
* intercept removed. Requests from *other* contexts are not seen.
|
|
486
|
+
*
|
|
487
|
+
* @example
|
|
488
|
+
* ```ts
|
|
489
|
+
* // Mock the authenticated user endpoint just for Alice's context.
|
|
490
|
+
* await alice.route('**\/api/me', (req) => ({
|
|
491
|
+
* status: 200,
|
|
492
|
+
* headers: { 'content-type': 'application/json' },
|
|
493
|
+
* body: { id: 1, name: 'Alice' },
|
|
494
|
+
* }));
|
|
495
|
+
* ```
|
|
496
|
+
*/
|
|
497
|
+
async route(pattern, handler) {
|
|
498
|
+
this.assertOpen();
|
|
499
|
+
if (!this._hooks) {
|
|
500
|
+
throw new Error('ctx.route() requires a network interceptor — this BrowserContext was ' +
|
|
501
|
+
'constructed without one. Create the context via browser.newContext().');
|
|
502
|
+
}
|
|
503
|
+
await this._ensurePageTracking();
|
|
504
|
+
// Synthetic id returned to the user. Stable across page churn.
|
|
505
|
+
const syntheticId = `ctx-route-${++BrowserContext._routeCounter}`;
|
|
506
|
+
const entry = { pattern, handler, perPage: new Map() };
|
|
507
|
+
this._routes.set(syntheticId, entry);
|
|
508
|
+
// Register intercept on every page that already exists in this context.
|
|
509
|
+
await Promise.all([...this._pageIds].map((pageId) => this._registerRouteOnPage(syntheticId, pageId).catch(() => { })));
|
|
510
|
+
return syntheticId;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Remove a route previously registered with {@link route}. With no
|
|
514
|
+
* argument, removes every route registered on this context.
|
|
515
|
+
*
|
|
516
|
+
* @example
|
|
517
|
+
* ```ts
|
|
518
|
+
* const id = await ctx.route('**\/api/feature-flags', mockFlags);
|
|
519
|
+
* // …test runs…
|
|
520
|
+
* await ctx.unroute(id);
|
|
521
|
+
* ```
|
|
522
|
+
*/
|
|
523
|
+
async unroute(id) {
|
|
524
|
+
if (!this._hooks)
|
|
525
|
+
return;
|
|
526
|
+
const net = this._hooks.getNetwork();
|
|
527
|
+
const remove = async (syntheticId) => {
|
|
528
|
+
const entry = this._routes.get(syntheticId);
|
|
529
|
+
if (!entry)
|
|
530
|
+
return;
|
|
531
|
+
this._routes.delete(syntheticId);
|
|
532
|
+
for (const realId of entry.perPage.values()) {
|
|
533
|
+
if (!realId)
|
|
534
|
+
continue; // placeholder for an in-flight registration
|
|
535
|
+
await net.removeIntercept(realId).catch(() => { });
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
if (id) {
|
|
539
|
+
await remove(id);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
for (const sid of [...this._routes.keys()]) {
|
|
543
|
+
await remove(sid);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// ── Extra HTTP headers ───────────────────────────────────────────────────
|
|
547
|
+
/**
|
|
548
|
+
* Replace this context's extra-HTTP-headers map. Applied to every
|
|
549
|
+
* currently-open page in this context, and re-applied to each new page
|
|
550
|
+
* that opens afterwards.
|
|
551
|
+
*
|
|
552
|
+
* Pass `{}` to clear.
|
|
553
|
+
*
|
|
554
|
+
* @example
|
|
555
|
+
* ```ts
|
|
556
|
+
* await alice.setExtraHTTPHeaders({ 'X-Tenant': 'acme', 'X-Test': 'true' });
|
|
557
|
+
* ```
|
|
558
|
+
*/
|
|
559
|
+
async setExtraHTTPHeaders(headers) {
|
|
560
|
+
this.assertOpen();
|
|
561
|
+
this._config.extraHTTPHeaders = { ...headers };
|
|
562
|
+
await this._ensurePageTracking();
|
|
563
|
+
if (this._pageIds.size > 0) {
|
|
564
|
+
await this._applyExtraHeadersTo([...this._pageIds]);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// ── Identity & device emulation (Milestone C) ────────────────────────────
|
|
568
|
+
/**
|
|
569
|
+
* Override the locale reported to every page in this context.
|
|
570
|
+
*
|
|
571
|
+
* Mapped to BiDi `emulation.setLocaleOverride` scoped to
|
|
572
|
+
* `userContexts: [this.id]`, so the override applies to every page
|
|
573
|
+
* currently open in this context **and** every page opened later.
|
|
574
|
+
* Pass `null` to clear the override.
|
|
575
|
+
*
|
|
576
|
+
* Cross-browser: works on Chrome and Firefox.
|
|
577
|
+
*
|
|
578
|
+
* @example
|
|
579
|
+
* ```ts
|
|
580
|
+
* // Render the UI in German for a checkout-translation test.
|
|
581
|
+
* await ctx.setLocale('de-DE');
|
|
582
|
+
* const page = await ctx.newPage({ url: '/checkout' });
|
|
583
|
+
* await page.expect(page.find('h1')).toContainText('Kasse');
|
|
584
|
+
* ```
|
|
585
|
+
*/
|
|
586
|
+
async setLocale(locale) {
|
|
587
|
+
this.assertOpen();
|
|
588
|
+
await this.conn.send('emulation.setLocaleOverride', {
|
|
589
|
+
userContexts: [this._id],
|
|
590
|
+
locale,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Override the timezone reported to every page in this context.
|
|
595
|
+
*
|
|
596
|
+
* Mapped to BiDi `emulation.setTimezoneOverride` scoped to
|
|
597
|
+
* `userContexts: [this.id]`. Pass `null` to clear the override.
|
|
598
|
+
* Accepts any IANA timezone id (e.g. `'Europe/Berlin'`,
|
|
599
|
+
* `'America/Los_Angeles'`).
|
|
600
|
+
*
|
|
601
|
+
* Cross-browser: works on Chrome and Firefox.
|
|
602
|
+
*
|
|
603
|
+
* @example
|
|
604
|
+
* ```ts
|
|
605
|
+
* await ctx.setTimezone('Asia/Tokyo');
|
|
606
|
+
* const page = await ctx.newPage({ url: '/dashboard' });
|
|
607
|
+
* // `new Date().getTimezoneOffset()` in the page now reflects JST.
|
|
608
|
+
* ```
|
|
609
|
+
*/
|
|
610
|
+
async setTimezone(timezoneId) {
|
|
611
|
+
this.assertOpen();
|
|
612
|
+
await this.conn.send('emulation.setTimezoneOverride', {
|
|
613
|
+
userContexts: [this._id],
|
|
614
|
+
timezone: timezoneId,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Override `navigator.geolocation` for every page in this context.
|
|
619
|
+
*
|
|
620
|
+
* Mapped to BiDi `emulation.setGeolocationOverride` scoped to
|
|
621
|
+
* `userContexts: [this.id]`. Pass `null` to clear and return to the
|
|
622
|
+
* real device location.
|
|
623
|
+
*
|
|
624
|
+
* The page still has to be granted the `geolocation` permission \u2014 use
|
|
625
|
+
* {@link grantPermissions} once the origin is known.
|
|
626
|
+
*
|
|
627
|
+
* @example
|
|
628
|
+
* ```ts
|
|
629
|
+
* await ctx.setGeolocation({ latitude: 51.5074, longitude: -0.1278 });
|
|
630
|
+
* const page = await ctx.newPage({ url: 'https://app.example.com' });
|
|
631
|
+
* await ctx.grantPermissions(['geolocation'], { origin: 'https://app.example.com' });
|
|
632
|
+
* ```
|
|
633
|
+
*/
|
|
634
|
+
async setGeolocation(coords) {
|
|
635
|
+
this.assertOpen();
|
|
636
|
+
const params = { userContexts: [this._id] };
|
|
637
|
+
if (coords === null) {
|
|
638
|
+
params.coordinates = null;
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
if (typeof coords.latitude !== 'number' ||
|
|
642
|
+
typeof coords.longitude !== 'number' ||
|
|
643
|
+
coords.latitude < -90 || coords.latitude > 90 ||
|
|
644
|
+
coords.longitude < -180 || coords.longitude > 180) {
|
|
645
|
+
throw new Error(`setGeolocation: invalid coordinates ${JSON.stringify(coords)}. ` +
|
|
646
|
+
'latitude must be in [-90, 90] and longitude in [-180, 180].');
|
|
647
|
+
}
|
|
648
|
+
params.coordinates = {
|
|
649
|
+
latitude: coords.latitude,
|
|
650
|
+
longitude: coords.longitude,
|
|
651
|
+
accuracy: coords.accuracy ?? 1,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
try {
|
|
655
|
+
await this.conn.send('emulation.setGeolocationOverride', params);
|
|
656
|
+
}
|
|
657
|
+
catch (err) {
|
|
658
|
+
const engine = this._hooks?.getBrowserName?.() ?? 'this browser';
|
|
659
|
+
throw new Error(`setGeolocation() failed on ${engine}: ${err.message}. ` +
|
|
660
|
+
'BiDi `emulation.setGeolocationOverride` coverage is still uneven ' +
|
|
661
|
+
'across engines; Chrome is the reliable target today.');
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Grant (or deny) one or more permissions for an origin in this context.
|
|
666
|
+
*
|
|
667
|
+
* Mapped to BiDi `permissions.setPermission` with the `userContext`
|
|
668
|
+
* parameter, so the grant only affects pages inside **this** context.
|
|
669
|
+
* The set of valid permission names is the W3C Permissions catalogue
|
|
670
|
+
* (`'geolocation'`, `'notifications'`, `'clipboard-read'`,
|
|
671
|
+
* `'clipboard-write'`, `'camera'`, `'microphone'`, \u2026).
|
|
672
|
+
*
|
|
673
|
+
* `opts.origin` is required \u2014 BiDi has no "all origins" wildcard.
|
|
674
|
+
* `opts.state` defaults to `'granted'`.
|
|
675
|
+
*
|
|
676
|
+
* @example
|
|
677
|
+
* ```ts
|
|
678
|
+
* await ctx.grantPermissions(['geolocation'], { origin: 'https://app.example.com' });
|
|
679
|
+
* await ctx.grantPermissions(['notifications'], {
|
|
680
|
+
* origin: 'https://app.example.com',
|
|
681
|
+
* state: 'denied',
|
|
682
|
+
* });
|
|
683
|
+
* ```
|
|
684
|
+
*/
|
|
685
|
+
async grantPermissions(permissions, opts) {
|
|
686
|
+
this.assertOpen();
|
|
687
|
+
if (!Array.isArray(permissions) || permissions.length === 0) {
|
|
688
|
+
throw new Error('grantPermissions: pass a non-empty array of permission names.');
|
|
689
|
+
}
|
|
690
|
+
if (!opts || typeof opts.origin !== 'string' || opts.origin.length === 0) {
|
|
691
|
+
throw new Error('grantPermissions: `origin` is required \u2014 BiDi `permissions.setPermission` ' +
|
|
692
|
+
'has no "all origins" wildcard.');
|
|
693
|
+
}
|
|
694
|
+
const state = opts.state ?? 'granted';
|
|
695
|
+
for (const name of permissions) {
|
|
696
|
+
try {
|
|
697
|
+
await this.conn.send('permissions.setPermission', {
|
|
698
|
+
descriptor: { name },
|
|
699
|
+
state,
|
|
700
|
+
origin: opts.origin,
|
|
701
|
+
userContext: this._id,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
catch (err) {
|
|
705
|
+
const engine = this._hooks?.getBrowserName?.() ?? 'this browser';
|
|
706
|
+
throw new Error(`grantPermissions(${JSON.stringify(name)}) failed on ${engine}: ` +
|
|
707
|
+
`${err.message}.`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Reset the listed permissions for an origin back to the browser
|
|
713
|
+
* default (`'prompt'`). Convenience wrapper around
|
|
714
|
+
* {@link grantPermissions} with `state: 'prompt'`.
|
|
715
|
+
*/
|
|
716
|
+
async clearPermissions(permissions, opts) {
|
|
717
|
+
await this.grantPermissions(permissions, { ...opts, state: 'prompt' });
|
|
718
|
+
}
|
|
719
|
+
// ── Internal helpers ─────────────────────────────────────────────────────
|
|
720
|
+
/**
|
|
721
|
+
* Read localStorage for every origin currently open in this context.
|
|
722
|
+
* Returns `{ origin: { key: value, ... }, ... }`.
|
|
723
|
+
*/
|
|
724
|
+
async collectLocalStorage() {
|
|
725
|
+
const pages = await this.pages();
|
|
726
|
+
const byOrigin = {};
|
|
727
|
+
for (const page of pages) {
|
|
728
|
+
try {
|
|
729
|
+
const url = await page.url();
|
|
730
|
+
if (!url || url === 'about:blank')
|
|
731
|
+
continue;
|
|
732
|
+
const origin = new URL(url).origin;
|
|
733
|
+
if (byOrigin[origin])
|
|
734
|
+
continue; // first page wins
|
|
735
|
+
const entries = await page.evaluate(`const out = {};
|
|
736
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
737
|
+
const k = localStorage.key(i);
|
|
738
|
+
if (k !== null) out[k] = localStorage.getItem(k) ?? '';
|
|
739
|
+
}
|
|
740
|
+
return out;`);
|
|
741
|
+
if (entries && Object.keys(entries).length > 0) {
|
|
742
|
+
byOrigin[origin] = entries;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
// Page may have been closed mid-snapshot; skip.
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return byOrigin;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Install a preload script (scoped to this user context) that, on first
|
|
753
|
+
* navigation to each captured origin, writes the localStorage entries
|
|
754
|
+
* before any page script runs.
|
|
755
|
+
*/
|
|
756
|
+
async installLocalStoragePreload(localStorage) {
|
|
757
|
+
// Serialize the origin → entries map into the function body. Safe because
|
|
758
|
+
// we control the JSON shape (strings only).
|
|
759
|
+
const payload = JSON.stringify(localStorage);
|
|
760
|
+
const fnSrc = `() => {
|
|
761
|
+
try {
|
|
762
|
+
const data = ${payload};
|
|
763
|
+
const entries = data[location.origin];
|
|
764
|
+
if (!entries) return;
|
|
765
|
+
for (const k in entries) {
|
|
766
|
+
try { window.localStorage.setItem(k, entries[k]); } catch (e) { /* quota / opaque origin */ }
|
|
767
|
+
}
|
|
768
|
+
} catch (e) { /* never break user pages */ }
|
|
769
|
+
}`;
|
|
770
|
+
const result = await this.conn.send('script.addPreloadScript', {
|
|
771
|
+
functionDeclaration: fnSrc,
|
|
772
|
+
userContexts: [this._id],
|
|
773
|
+
});
|
|
774
|
+
return result.script;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Subscribe (once) to BiDi page-creation / page-destruction events and
|
|
778
|
+
* seed `_pageIds` with currently-open pages in this context. Powers
|
|
779
|
+
* both the `'page'` event and route membership filtering.
|
|
780
|
+
*/
|
|
781
|
+
_ensurePageTracking() {
|
|
782
|
+
if (this._closed)
|
|
783
|
+
return Promise.resolve();
|
|
784
|
+
if (this._trackingPromise)
|
|
785
|
+
return this._trackingPromise;
|
|
786
|
+
this._trackingPromise = this._startPageTracking();
|
|
787
|
+
return this._trackingPromise;
|
|
788
|
+
}
|
|
789
|
+
async _startPageTracking() {
|
|
790
|
+
await this.conn
|
|
791
|
+
.subscribe(['browsingContext.contextCreated', 'browsingContext.contextDestroyed'])
|
|
792
|
+
.catch(() => { });
|
|
793
|
+
const onCreated = (params) => {
|
|
794
|
+
if (params.parent)
|
|
795
|
+
return; // nested frames
|
|
796
|
+
if (params.userContext !== this._id)
|
|
797
|
+
return;
|
|
798
|
+
const id = params.context;
|
|
799
|
+
this._pageIds.add(id);
|
|
800
|
+
// Headers must be applied before the first request from this page.
|
|
801
|
+
this._applyExtraHeadersTo([id]).catch(() => { });
|
|
802
|
+
// Register every existing route on the new page.
|
|
803
|
+
for (const sid of this._routes.keys()) {
|
|
804
|
+
this._registerRouteOnPage(sid, id).catch(() => { });
|
|
805
|
+
}
|
|
806
|
+
// Notify page listeners exactly once per page.
|
|
807
|
+
if (this._notifiedPageIds.has(id))
|
|
808
|
+
return;
|
|
809
|
+
this._notifiedPageIds.add(id);
|
|
810
|
+
if (this._pageListeners.size > 0) {
|
|
811
|
+
const page = new Page(this.driver, id, this.getDefaultTimeout, this.conn, this);
|
|
812
|
+
for (const fn of this._pageListeners) {
|
|
813
|
+
try {
|
|
814
|
+
fn(page);
|
|
815
|
+
}
|
|
816
|
+
catch { /* listener errors must not break events */ }
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
const onDestroyed = (params) => {
|
|
821
|
+
if (params.parent)
|
|
822
|
+
return;
|
|
823
|
+
const id = params.context;
|
|
824
|
+
this._pageIds.delete(id);
|
|
825
|
+
this._notifiedPageIds.delete(id);
|
|
826
|
+
// Tear down any per-page intercepts registered against this page.
|
|
827
|
+
const net = this._hooks?.getNetwork();
|
|
828
|
+
for (const entry of this._routes.values()) {
|
|
829
|
+
const realId = entry.perPage.get(id);
|
|
830
|
+
if (realId && net) {
|
|
831
|
+
entry.perPage.delete(id);
|
|
832
|
+
net.removeIntercept(realId).catch(() => { });
|
|
833
|
+
}
|
|
834
|
+
else if (realId === '') {
|
|
835
|
+
// In-flight registration — drop the placeholder; the registration
|
|
836
|
+
// will detect the missing page and clean itself up.
|
|
837
|
+
entry.perPage.delete(id);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
this._trackingOffs.push(this.conn.on('browsingContext.contextCreated', onCreated));
|
|
842
|
+
this._trackingOffs.push(this.conn.on('browsingContext.contextDestroyed', onDestroyed));
|
|
843
|
+
// Seed with currently-open pages AFTER handlers are attached so we don't
|
|
844
|
+
// miss a contextCreated event that races with this seeding.
|
|
845
|
+
try {
|
|
846
|
+
const tree = await this.conn.send('browsingContext.getTree', {});
|
|
847
|
+
for (const c of tree.contexts ?? []) {
|
|
848
|
+
if (c.userContext === this._id && !c.parent) {
|
|
849
|
+
this._pageIds.add(c.context);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
catch { /* not fatal — set will fill from events */ }
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Push the current `extraHTTPHeaders` onto a specific set of page ids.
|
|
857
|
+
* No-op when no headers are configured.
|
|
858
|
+
*/
|
|
859
|
+
async _applyExtraHeadersTo(contexts) {
|
|
860
|
+
const headers = this._config.extraHTTPHeaders;
|
|
861
|
+
if (!headers || Object.keys(headers).length === 0 || contexts.length === 0)
|
|
862
|
+
return;
|
|
863
|
+
if (!this._hooks)
|
|
864
|
+
return;
|
|
865
|
+
await this._hooks.getNetwork().setExtraHeaders(headers, contexts);
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Register a single route's BiDi intercept against one page id. Records
|
|
869
|
+
* the live intercept id in the route's `perPage` map so it can be
|
|
870
|
+
* cleaned up when the page closes or the route is removed.
|
|
871
|
+
*
|
|
872
|
+
* Reserves the slot synchronously (before awaiting `intercept`) so that
|
|
873
|
+
* concurrent callers — typically `newPage()` and the `contextCreated`
|
|
874
|
+
* event firing for the same page — don't both create an intercept.
|
|
875
|
+
*/
|
|
876
|
+
async _registerRouteOnPage(syntheticId, pageId) {
|
|
877
|
+
const entry = this._routes.get(syntheticId);
|
|
878
|
+
if (!entry || !this._hooks)
|
|
879
|
+
return;
|
|
880
|
+
if (entry.perPage.has(pageId))
|
|
881
|
+
return;
|
|
882
|
+
// Reserve before the await with a placeholder so a parallel registration
|
|
883
|
+
// call observes `perPage.has(pageId)` === true and bails out early.
|
|
884
|
+
entry.perPage.set(pageId, '');
|
|
885
|
+
try {
|
|
886
|
+
const realId = await this._hooks.getNetwork().intercept(entry.pattern, entry.handler, ['beforeRequestSent'], [pageId]);
|
|
887
|
+
// The route may have been unrouted, or the page destroyed (which
|
|
888
|
+
// clears the placeholder via `onDestroyed`), while we were awaiting.
|
|
889
|
+
// In either case, drop the intercept and leave no stale entry behind.
|
|
890
|
+
if (!this._routes.has(syntheticId) || !this._pageIds.has(pageId)) {
|
|
891
|
+
entry.perPage.delete(pageId);
|
|
892
|
+
await this._hooks.getNetwork().removeIntercept(realId).catch(() => { });
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
entry.perPage.set(pageId, realId);
|
|
896
|
+
}
|
|
897
|
+
catch (err) {
|
|
898
|
+
// Roll back the placeholder so a later retry can succeed.
|
|
899
|
+
if (entry.perPage.get(pageId) === '')
|
|
900
|
+
entry.perPage.delete(pageId);
|
|
901
|
+
throw err;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
// ── Module-private helpers ─────────────────────────────────────────────────
|
|
906
|
+
function normalizeCookie(raw) {
|
|
907
|
+
return {
|
|
908
|
+
name: raw.name,
|
|
909
|
+
value: typeof raw.value === 'string' ? raw.value : raw.value.value,
|
|
910
|
+
domain: raw.domain,
|
|
911
|
+
path: raw.path,
|
|
912
|
+
size: raw.size,
|
|
913
|
+
httpOnly: raw.httpOnly,
|
|
914
|
+
secure: raw.secure,
|
|
915
|
+
sameSite: raw.sameSite,
|
|
916
|
+
expiry: raw.expiry,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
/** Would `cookie` be sent on a request to `url`? RFC-6265 domain/path match (best effort). */
|
|
920
|
+
function cookieMatchesUrl(cookie, url) {
|
|
921
|
+
const cookieDomain = cookie.domain.replace(/^\./, '').toLowerCase();
|
|
922
|
+
const reqHost = url.hostname.toLowerCase();
|
|
923
|
+
const domainOk = reqHost === cookieDomain || reqHost.endsWith('.' + cookieDomain);
|
|
924
|
+
if (!domainOk)
|
|
925
|
+
return false;
|
|
926
|
+
const cookiePath = cookie.path || '/';
|
|
927
|
+
const reqPath = url.pathname || '/';
|
|
928
|
+
const pathOk = cookiePath === '/' ||
|
|
929
|
+
reqPath === cookiePath ||
|
|
930
|
+
reqPath.startsWith(cookiePath.endsWith('/') ? cookiePath : cookiePath + '/');
|
|
931
|
+
if (!pathOk)
|
|
932
|
+
return false;
|
|
933
|
+
if (cookie.secure && url.protocol !== 'https:')
|
|
934
|
+
return false;
|
|
935
|
+
return true;
|
|
936
|
+
}
|
|
937
|
+
//# sourceMappingURL=browserContext.js.map
|