craftdriver 1.7.0 → 1.9.0

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.
Files changed (234) hide show
  1. package/CHANGELOG.md +25 -12
  2. package/README.md +16 -6
  3. package/dist/cli/agentSession.d.ts +62 -0
  4. package/dist/cli/agentSession.d.ts.map +1 -0
  5. package/dist/cli/agentSession.js +100 -0
  6. package/dist/cli/agentSession.js.map +1 -0
  7. package/dist/cli/artifactPaths.d.ts +42 -0
  8. package/dist/cli/artifactPaths.d.ts.map +1 -0
  9. package/dist/cli/artifactPaths.js +144 -0
  10. package/dist/cli/artifactPaths.js.map +1 -0
  11. package/dist/cli/bounds.d.ts +88 -0
  12. package/dist/cli/bounds.d.ts.map +1 -0
  13. package/dist/cli/bounds.js +128 -0
  14. package/dist/cli/bounds.js.map +1 -0
  15. package/dist/cli/client.d.ts +3 -0
  16. package/dist/cli/client.d.ts.map +1 -1
  17. package/dist/cli/client.js +24 -8
  18. package/dist/cli/client.js.map +1 -1
  19. package/dist/cli/daemon.d.ts +3 -0
  20. package/dist/cli/daemon.d.ts.map +1 -1
  21. package/dist/cli/daemon.js +152 -39
  22. package/dist/cli/daemon.js.map +1 -1
  23. package/dist/cli/defaults.d.ts +23 -3
  24. package/dist/cli/defaults.d.ts.map +1 -1
  25. package/dist/cli/defaults.js +50 -5
  26. package/dist/cli/defaults.js.map +1 -1
  27. package/dist/cli/dispatcher.d.ts +54 -11
  28. package/dist/cli/dispatcher.d.ts.map +1 -1
  29. package/dist/cli/dispatcher.js +851 -54
  30. package/dist/cli/dispatcher.js.map +1 -1
  31. package/dist/cli/index.d.ts.map +1 -1
  32. package/dist/cli/index.js +305 -56
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/init.d.ts +14 -14
  35. package/dist/cli/init.d.ts.map +1 -1
  36. package/dist/cli/init.js +328 -180
  37. package/dist/cli/init.js.map +1 -1
  38. package/dist/cli/journal.d.ts +153 -0
  39. package/dist/cli/journal.d.ts.map +1 -0
  40. package/dist/cli/journal.js +252 -0
  41. package/dist/cli/journal.js.map +1 -0
  42. package/dist/cli/lineReader.d.ts +33 -0
  43. package/dist/cli/lineReader.d.ts.map +1 -0
  44. package/dist/cli/lineReader.js +66 -0
  45. package/dist/cli/lineReader.js.map +1 -0
  46. package/dist/cli/locatorCandidates.d.ts +44 -0
  47. package/dist/cli/locatorCandidates.d.ts.map +1 -0
  48. package/dist/cli/locatorCandidates.js +190 -0
  49. package/dist/cli/locatorCandidates.js.map +1 -0
  50. package/dist/cli/mcp/artifacts.d.ts +39 -1
  51. package/dist/cli/mcp/artifacts.d.ts.map +1 -1
  52. package/dist/cli/mcp/artifacts.js +104 -9
  53. package/dist/cli/mcp/artifacts.js.map +1 -1
  54. package/dist/cli/mcp/bounds.d.ts +65 -0
  55. package/dist/cli/mcp/bounds.d.ts.map +1 -0
  56. package/dist/cli/mcp/bounds.js +124 -0
  57. package/dist/cli/mcp/bounds.js.map +1 -0
  58. package/dist/cli/mcp/params.d.ts +38 -0
  59. package/dist/cli/mcp/params.d.ts.map +1 -0
  60. package/dist/cli/mcp/params.js +174 -0
  61. package/dist/cli/mcp/params.js.map +1 -0
  62. package/dist/cli/mcp/server.d.ts +94 -16
  63. package/dist/cli/mcp/server.d.ts.map +1 -1
  64. package/dist/cli/mcp/server.js +304 -150
  65. package/dist/cli/mcp/server.js.map +1 -1
  66. package/dist/cli/mcp/tools.d.ts +62 -17
  67. package/dist/cli/mcp/tools.d.ts.map +1 -1
  68. package/dist/cli/mcp/tools.js +373 -158
  69. package/dist/cli/mcp/tools.js.map +1 -1
  70. package/dist/cli/pageSemantics.d.ts +6 -0
  71. package/dist/cli/pageSemantics.d.ts.map +1 -0
  72. package/dist/cli/pageSemantics.js +6 -0
  73. package/dist/cli/pageSemantics.js.map +1 -0
  74. package/dist/cli/parseArgs.d.ts +6 -18
  75. package/dist/cli/parseArgs.d.ts.map +1 -1
  76. package/dist/cli/parseArgs.js +689 -45
  77. package/dist/cli/parseArgs.js.map +1 -1
  78. package/dist/cli/protocol.d.ts +8 -0
  79. package/dist/cli/protocol.d.ts.map +1 -1
  80. package/dist/cli/selector.d.ts +3 -3
  81. package/dist/cli/selector.d.ts.map +1 -1
  82. package/dist/cli/selector.js +6 -9
  83. package/dist/cli/selector.js.map +1 -1
  84. package/dist/cli/sessionRegistry.d.ts +42 -0
  85. package/dist/cli/sessionRegistry.d.ts.map +1 -0
  86. package/dist/cli/sessionRegistry.js +99 -0
  87. package/dist/cli/sessionRegistry.js.map +1 -0
  88. package/dist/cli/snapshot.d.ts +100 -19
  89. package/dist/cli/snapshot.d.ts.map +1 -1
  90. package/dist/cli/snapshot.js +295 -108
  91. package/dist/cli/snapshot.js.map +1 -1
  92. package/dist/cli/stateStore.d.ts +50 -0
  93. package/dist/cli/stateStore.d.ts.map +1 -0
  94. package/dist/cli/stateStore.js +122 -0
  95. package/dist/cli/stateStore.js.map +1 -0
  96. package/dist/index.d.ts +3 -2
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +1 -0
  99. package/dist/index.js.map +1 -1
  100. package/dist/lib/a11y.d.ts +6 -2
  101. package/dist/lib/a11y.d.ts.map +1 -1
  102. package/dist/lib/a11y.js +3 -1
  103. package/dist/lib/a11y.js.map +1 -1
  104. package/dist/lib/bidi/evaluate.d.ts +11 -0
  105. package/dist/lib/bidi/evaluate.d.ts.map +1 -0
  106. package/dist/lib/bidi/evaluate.js +25 -0
  107. package/dist/lib/bidi/evaluate.js.map +1 -0
  108. package/dist/lib/bidi/index.d.ts +1 -1
  109. package/dist/lib/bidi/index.d.ts.map +1 -1
  110. package/dist/lib/bidi/index.js +2 -2
  111. package/dist/lib/bidi/index.js.map +1 -1
  112. package/dist/lib/bidi/logs.d.ts +3 -0
  113. package/dist/lib/bidi/logs.d.ts.map +1 -1
  114. package/dist/lib/bidi/logs.js +7 -0
  115. package/dist/lib/bidi/logs.js.map +1 -1
  116. package/dist/lib/bidi/network.d.ts +14 -1
  117. package/dist/lib/bidi/network.d.ts.map +1 -1
  118. package/dist/lib/bidi/network.js +45 -7
  119. package/dist/lib/bidi/network.js.map +1 -1
  120. package/dist/lib/bidi/storage.d.ts +30 -6
  121. package/dist/lib/bidi/storage.d.ts.map +1 -1
  122. package/dist/lib/bidi/storage.js +190 -45
  123. package/dist/lib/bidi/storage.js.map +1 -1
  124. package/dist/lib/bidi/types.d.ts +9 -2
  125. package/dist/lib/bidi/types.d.ts.map +1 -1
  126. package/dist/lib/browser.d.ts +7 -4
  127. package/dist/lib/browser.d.ts.map +1 -1
  128. package/dist/lib/browser.js +186 -65
  129. package/dist/lib/browser.js.map +1 -1
  130. package/dist/lib/browserContext.d.ts +58 -24
  131. package/dist/lib/browserContext.d.ts.map +1 -1
  132. package/dist/lib/browserContext.js +300 -99
  133. package/dist/lib/browserContext.js.map +1 -1
  134. package/dist/lib/by.d.ts +68 -30
  135. package/dist/lib/by.d.ts.map +1 -1
  136. package/dist/lib/by.js +54 -23
  137. package/dist/lib/by.js.map +1 -1
  138. package/dist/lib/clearFastPath.d.ts.map +1 -1
  139. package/dist/lib/clearFastPath.js +2 -0
  140. package/dist/lib/clearFastPath.js.map +1 -1
  141. package/dist/lib/clickFastPath.d.ts.map +1 -1
  142. package/dist/lib/clickFastPath.js +2 -0
  143. package/dist/lib/clickFastPath.js.map +1 -1
  144. package/dist/lib/driver.d.ts +26 -0
  145. package/dist/lib/driver.d.ts.map +1 -1
  146. package/dist/lib/driver.js +123 -4
  147. package/dist/lib/driver.js.map +1 -1
  148. package/dist/lib/elementHandle.d.ts +13 -10
  149. package/dist/lib/elementHandle.d.ts.map +1 -1
  150. package/dist/lib/elementHandle.js +119 -50
  151. package/dist/lib/elementHandle.js.map +1 -1
  152. package/dist/lib/errors.d.ts +12 -0
  153. package/dist/lib/errors.d.ts.map +1 -1
  154. package/dist/lib/errors.js +12 -0
  155. package/dist/lib/errors.js.map +1 -1
  156. package/dist/lib/expect.d.ts +10 -0
  157. package/dist/lib/expect.d.ts.map +1 -1
  158. package/dist/lib/expect.js +76 -49
  159. package/dist/lib/expect.js.map +1 -1
  160. package/dist/lib/fillFastPath.d.ts.map +1 -1
  161. package/dist/lib/fillFastPath.js +2 -0
  162. package/dist/lib/fillFastPath.js.map +1 -1
  163. package/dist/lib/frame.d.ts.map +1 -1
  164. package/dist/lib/frame.js +29 -17
  165. package/dist/lib/frame.js.map +1 -1
  166. package/dist/lib/initScript.d.ts +9 -0
  167. package/dist/lib/initScript.d.ts.map +1 -0
  168. package/dist/lib/initScript.js +14 -0
  169. package/dist/lib/initScript.js.map +1 -0
  170. package/dist/lib/locator.d.ts +21 -54
  171. package/dist/lib/locator.d.ts.map +1 -1
  172. package/dist/lib/locator.js +165 -208
  173. package/dist/lib/locator.js.map +1 -1
  174. package/dist/lib/page.d.ts +22 -0
  175. package/dist/lib/page.d.ts.map +1 -1
  176. package/dist/lib/page.js +71 -13
  177. package/dist/lib/page.js.map +1 -1
  178. package/dist/lib/pageSemantics.d.ts +42 -0
  179. package/dist/lib/pageSemantics.d.ts.map +1 -0
  180. package/dist/lib/pageSemantics.js +216 -0
  181. package/dist/lib/pageSemantics.js.map +1 -0
  182. package/dist/lib/query.d.ts +68 -0
  183. package/dist/lib/query.d.ts.map +1 -0
  184. package/dist/lib/query.js +590 -0
  185. package/dist/lib/query.js.map +1 -0
  186. package/dist/lib/secureFile.d.ts +8 -0
  187. package/dist/lib/secureFile.d.ts.map +1 -0
  188. package/dist/lib/secureFile.js +67 -0
  189. package/dist/lib/secureFile.js.map +1 -0
  190. package/dist/lib/sessionStateValidation.d.ts +35 -0
  191. package/dist/lib/sessionStateValidation.d.ts.map +1 -0
  192. package/dist/lib/sessionStateValidation.js +223 -0
  193. package/dist/lib/sessionStateValidation.js.map +1 -0
  194. package/dist/lib/shadowRoot.d.ts +21 -0
  195. package/dist/lib/shadowRoot.d.ts.map +1 -0
  196. package/dist/lib/shadowRoot.js +29 -0
  197. package/dist/lib/shadowRoot.js.map +1 -0
  198. package/dist/lib/shadowRootLocator.d.ts +31 -0
  199. package/dist/lib/shadowRootLocator.d.ts.map +1 -0
  200. package/dist/lib/shadowRootLocator.js +55 -0
  201. package/dist/lib/shadowRootLocator.js.map +1 -0
  202. package/dist/lib/timing.d.ts +2 -2
  203. package/dist/lib/timing.js +2 -2
  204. package/dist/lib/tracing.d.ts +2 -1
  205. package/dist/lib/tracing.d.ts.map +1 -1
  206. package/dist/lib/tracing.js +12 -1
  207. package/dist/lib/tracing.js.map +1 -1
  208. package/docs/accessibility.md +9 -1
  209. package/docs/agents.md +56 -61
  210. package/docs/api-reference.md +5 -1
  211. package/docs/browser-api.md +27 -7
  212. package/docs/browser-context.md +21 -15
  213. package/docs/cli.md +353 -30
  214. package/docs/error-codes.md +7 -4
  215. package/docs/getting-started.md +6 -1
  216. package/docs/index.md +1 -1
  217. package/docs/mcp.md +136 -65
  218. package/docs/public/examples/agent-actions.html +48 -0
  219. package/docs/public/examples/agent-debug.html +86 -0
  220. package/docs/public/examples/agent-late-mutation.html +18 -0
  221. package/docs/public/examples/agent-ref-shift.html +33 -0
  222. package/docs/public/examples/iframe-child.html +12 -1
  223. package/docs/public/examples/shadow-dom.html +137 -0
  224. package/docs/recipes/login-once-reuse-session.md +33 -5
  225. package/docs/selectors.md +61 -0
  226. package/docs/session-management.md +80 -12
  227. package/docs/why-craftdriver.md +1 -1
  228. package/package.json +5 -3
  229. package/skills/craftdriver/SKILL.md +59 -75
  230. package/skills/craftdriver/agents/openai.yaml +4 -0
  231. package/skills/craftdriver/cheatsheet.md +18 -4
  232. package/skills/craftdriver/cli.md +194 -68
  233. package/skills/craftdriver/patterns.md +17 -8
  234. package/skills/craftdriver/workflow.md +149 -0
@@ -17,8 +17,11 @@
17
17
  * - `browser.defaultContext` — the implicit context the browser
18
18
  * started in (id = `'default'`).
19
19
  */
20
- import fs from 'node:fs/promises';
20
+ import { writeSecureFile } from './secureFile.js';
21
+ import { CraftdriverError, ErrorCode } from './errors.js';
22
+ import { parseSessionState, hasNonEmptySessionStorage, isStorageStateError, nonEmptyOrigins, normalizeCookieForRestore, storageStateDetail, } from './sessionStateValidation.js';
21
23
  import { Page } from './page.js';
24
+ import { AUTH_STATE_HYDRATE_PATH, publicPageInitScript } from './initScript.js';
22
25
  export class BrowserContext {
23
26
  /** Monotonic counter for synthetic route ids returned by `route()`. */
24
27
  static _routeCounter = 0;
@@ -31,12 +34,6 @@ export class BrowserContext {
31
34
  _hooks;
32
35
  /** Per-context config (baseURL, extraHTTPHeaders). Read by Page for URL resolution. */
33
36
  _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
37
  /** Preload-script ids registered via {@link addInitScript}. */
41
38
  _initScriptIds = new Set();
42
39
  /**
@@ -58,6 +55,23 @@ export class BrowserContext {
58
55
  * after the `browsingContext.contextCreated` event arrives.
59
56
  */
60
57
  _notifiedPageIds = new Set();
58
+ /**
59
+ * Ids of internal (hydration) top-level contexts that must stay invisible to
60
+ * page tracking — never surfaced as a `'page'`, added to `_pageIds`, or given
61
+ * user routes/headers. See {@link _hydrateLocalStorage}.
62
+ */
63
+ _internalPageIds = new Set();
64
+ /**
65
+ * `> 0` while an internal `browsingContext.create` is awaiting its id.
66
+ * Top-level `contextCreated` events that arrive in that window are quarantined
67
+ * (the event can beat the create response), then the internal one is dropped
68
+ * and the rest replayed in order once the id is known.
69
+ */
70
+ _internalCreateInFlight = 0;
71
+ _quarantinedCreated = [];
72
+ _internalClassificationWaiters = new Set();
73
+ /** Settled tail used to serialize state overlays within this user context. */
74
+ _restoreTail = Promise.resolve();
61
75
  /**
62
76
  * The in-flight promise from the most recent call to {@link _ensurePageTracking}.
63
77
  * Doubles as the "is tracking armed?" flag — truthy after the first call,
@@ -103,7 +117,6 @@ export class BrowserContext {
103
117
  /** @internal Used by Page to keep preload-backed navigations on BiDi. */
104
118
  _hasInitScriptsForNavigation() {
105
119
  return (this._initScriptIds.size > 0 ||
106
- this._localStoragePreloadId !== undefined ||
107
120
  this._hooks?.hasBrowserInitScripts?.() === true);
108
121
  }
109
122
  /** BiDi partition descriptor for storage operations scoped to this user context. */
@@ -146,8 +159,11 @@ export class BrowserContext {
146
159
  async pages() {
147
160
  this.assertOpen();
148
161
  const tree = await this.conn.send('browsingContext.getTree', { maxDepth: 0 });
162
+ // The create event can beat the command response that tells us which id is
163
+ // internal. Wait for that classification before exposing this tree.
164
+ await this._waitForInternalClassification();
149
165
  return (tree.contexts ?? [])
150
- .filter((c) => c.userContext === this._id && !c.parent)
166
+ .filter((c) => c.userContext === this._id && !c.parent && !this._internalPageIds.has(c.context))
151
167
  .map((c) => new Page(this.driver, c.context, this.getDefaultTimeout, this.conn, this));
152
168
  }
153
169
  /**
@@ -165,7 +181,9 @@ export class BrowserContext {
165
181
  if (this._trackingPromise)
166
182
  await this._trackingPromise;
167
183
  return new Promise((resolve, reject) => {
184
+ let settled = false;
168
185
  const timer = setTimeout(() => {
186
+ settled = true;
169
187
  off();
170
188
  reject(new Error(`waitForPage() timed out after ${timeout}ms`));
171
189
  }, timeout);
@@ -174,11 +192,27 @@ export class BrowserContext {
174
192
  return; // nested frames
175
193
  if (params.userContext !== this._id)
176
194
  return;
177
- clearTimeout(timer);
178
- off();
179
- resolve(new Page(this.driver, params.context, this.getDefaultTimeout, this.conn, this));
195
+ const id = params.context;
196
+ void this._isInternalPageContext(id).then((internal) => {
197
+ if (internal || settled)
198
+ return;
199
+ settled = true;
200
+ clearTimeout(timer);
201
+ off();
202
+ resolve(new Page(this.driver, id, this.getDefaultTimeout, this.conn, this));
203
+ }).catch((err) => {
204
+ if (settled)
205
+ return;
206
+ settled = true;
207
+ clearTimeout(timer);
208
+ off();
209
+ reject(err);
210
+ });
180
211
  });
181
212
  action().catch((err) => {
213
+ if (settled)
214
+ return;
215
+ settled = true;
182
216
  clearTimeout(timer);
183
217
  off();
184
218
  reject(err);
@@ -190,8 +224,7 @@ export class BrowserContext {
190
224
  * Return cookies scoped to this user context.
191
225
  *
192
226
  * Optionally filter by URL(s): only cookies whose `domain`/`path` would
193
- * be sent on a request to that URL are returned. Mirrors Playwright's
194
- * `BrowserContext.cookies(urls)` shape.
227
+ * be sent on a request to that URL are returned.
195
228
  *
196
229
  * @example
197
230
  * ```ts
@@ -270,13 +303,12 @@ export class BrowserContext {
270
303
  partition: this.partition(),
271
304
  });
272
305
  }
273
- // ── Storage state (Playwright-shape JSON) ────────────────────────────────
306
+ // ── Storage state (CraftDriver-native JSON) ─────────────────────────────
274
307
  /**
275
308
  * Snapshot cookies + localStorage for this context.
276
309
  *
277
- * Returns `{ cookies, localStorage }` a superset of Playwright's
278
- * `storageState()` JSON shape, kept close enough that dumped files
279
- * are interchangeable on a best-effort basis.
310
+ * Returns `{ cookies, localStorage }` in CraftDriver's native
311
+ * {@link SessionState} shape. It is not a cross-tool interchange format.
280
312
  *
281
313
  * **Limitation.** localStorage can only be read for origins that a page
282
314
  * in this context is currently visiting — BiDi has no out-of-band
@@ -307,59 +339,73 @@ export class BrowserContext {
307
339
  * const alice = await browser.newContext();
308
340
  * const page = await alice.newPage({ url: 'https://app.example.com/login' });
309
341
  * await loginAs(page, 'alice');
310
- * await alice.saveStorageState('auth/alice.json');
342
+ * await alice.saveStorageState('.auth/alice.json');
311
343
  * await alice.close();
312
344
  * ```
313
345
  */
314
346
  async saveStorageState(path, opts) {
315
347
  const state = await this.storageState(opts);
316
- await fs.writeFile(path, JSON.stringify(state, null, 2), 'utf-8');
348
+ await writeSecureFile(path, JSON.stringify(state, null, 2));
317
349
  return state;
318
350
  }
319
351
  /**
320
352
  * Apply a previously-captured snapshot to this context.
321
353
  *
322
354
  * - Cookies are written immediately.
323
- * - localStorage entries are installed via a preload script that runs
324
- * on first navigation to each origin, before any page script. This
325
- * matches the way Playwright restores localStorage and survives full
326
- * reloads.
327
- *
328
- * Calling `loadStorageState` again replaces the previous preload
329
- * script; it does not stack.
355
+ * - localStorage is seeded once per captured origin through a private,
356
+ * intercepted same-origin document (BiDi has no "set localStorage for
357
+ * origin" command). Because it runs once not as a per-navigation
358
+ * preload — the application owns the values afterward, so its own writes
359
+ * survive a reload.
330
360
  *
331
361
  * @param source A `SessionState` object, or a path to a JSON file
332
362
  * produced by {@link saveStorageState}.
333
363
  */
334
364
  async loadStorageState(source) {
365
+ return this._loadStorageState(source, 'BrowserContext.loadStorageState');
366
+ }
367
+ /** @internal Same public contract with an owning API name for structured errors. */
368
+ async _loadStorageState(source, operation) {
335
369
  this.assertOpen();
336
- const state = typeof source === 'string'
337
- ? JSON.parse(await fs.readFile(source, 'utf-8'))
338
- : source;
339
- if (state.cookies?.length) {
340
- // Sanitize: a snapshot may contain cookies with sameSite:'none' and
341
- // secure:false because some engines report that combo for cookies
342
- // originally set via document.cookie. BiDi rejects it. Drop the
343
- // sameSite field on those so the engine picks its default.
344
- const sanitized = state.cookies.map((c) => {
345
- if (c.sameSite === 'none' && !c.secure) {
346
- const { sameSite: _drop, ...rest } = c;
347
- return rest;
348
- }
349
- return c;
370
+ const errorContext = this._storageStateErrorContext(operation);
371
+ const state = await parseSessionState(source, errorContext);
372
+ // A context/launch restore cannot carry tab-scoped sessionStorage to the
373
+ // caller's future pages — reject before any mutation rather than silently
374
+ // dropping it. (browser.loadState() takes the active-page path for that.)
375
+ if (hasNonEmptySessionStorage(state)) {
376
+ throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'restoring sessionStorage is not supported for context/launch APIs (it is ' +
377
+ 'tab-scoped); use browser.loadState() after navigating to the origin', {
378
+ detail: storageStateDetail(errorContext, 'validation', { section: 'sessionStorage' }),
379
+ hint: 'Navigate to the origin and call browser.loadState(), or omit sessionStorage.',
350
380
  });
351
- await this.addCookies(sanitized);
352
- }
353
- // Replace any previously-installed localStorage preload.
354
- if (this._localStoragePreloadId) {
355
- await this.conn
356
- .send('script.removePreloadScript', { script: this._localStoragePreloadId })
357
- .catch(() => { });
358
- this._localStoragePreloadId = undefined;
359
- }
360
- if (state.localStorage && Object.keys(state.localStorage).length > 0) {
361
- this._localStoragePreloadId = await this.installLocalStoragePreload(state.localStorage);
362
381
  }
382
+ await this._runSerializedRestore(async () => {
383
+ let partialApplied = false;
384
+ // localStorage first — its private hydration page is where a restore failure
385
+ // would surface; applying cookies last means a hydration failure never
386
+ // leaves cookies behind.
387
+ if (nonEmptyOrigins(state.localStorage).length > 0) {
388
+ try {
389
+ partialApplied = await this._hydrateLocalStorage(state.localStorage, errorContext);
390
+ }
391
+ catch (err) {
392
+ if (isStorageStateError(err))
393
+ throw err;
394
+ throw this._restoreDriverError(err, errorContext, 'localStorage', partialApplied);
395
+ }
396
+ }
397
+ if (state.cookies?.length) {
398
+ for (const captured of state.cookies) {
399
+ try {
400
+ await this.addCookies([normalizeCookieForRestore(captured)]);
401
+ partialApplied = true;
402
+ }
403
+ catch (err) {
404
+ throw this._restoreDriverError(err, errorContext, 'cookies', partialApplied);
405
+ }
406
+ }
407
+ }
408
+ });
363
409
  }
364
410
  /**
365
411
  * Remove this user context. All of its pages are closed and any
@@ -381,12 +427,6 @@ export class BrowserContext {
381
427
  await this.conn.send('script.removePreloadScript', { script: id }).catch(() => { });
382
428
  }
383
429
  this._initScriptIds.clear();
384
- if (this._localStoragePreloadId) {
385
- await this.conn
386
- .send('script.removePreloadScript', { script: this._localStoragePreloadId })
387
- .catch(() => { });
388
- this._localStoragePreloadId = undefined;
389
- }
390
430
  if (this._hooks && this._routes.size > 0) {
391
431
  const net = this._hooks.getNetwork();
392
432
  for (const entry of this._routes.values()) {
@@ -402,6 +442,8 @@ export class BrowserContext {
402
442
  off();
403
443
  this._trackingOffs = [];
404
444
  this._trackingPromise = undefined;
445
+ this._internalPageIds.clear();
446
+ this._resolveInternalClassificationWaiters();
405
447
  await this.conn.send('browser.removeUserContext', { userContext: this._id });
406
448
  this._closed = true;
407
449
  // Fire close listeners last, after the context is fully gone.
@@ -454,7 +496,7 @@ export class BrowserContext {
454
496
  this.assertOpen();
455
497
  const fnDecl = typeof script === 'function' ? script.toString() : `() => { ${script} }`;
456
498
  const result = await this.conn.send('script.addPreloadScript', {
457
- functionDeclaration: fnDecl,
499
+ functionDeclaration: publicPageInitScript(fnDecl),
458
500
  userContexts: [this._id],
459
501
  });
460
502
  this._initScriptIds.add(result.script);
@@ -757,35 +799,197 @@ export class BrowserContext {
757
799
  return byOrigin;
758
800
  }
759
801
  /**
760
- * Install a preload script (scoped to this user context) that, on first
761
- * navigation to each captured origin, writes the localStorage entries
762
- * before any page script runs.
802
+ * Seed localStorage for each captured origin exactly once.
803
+ *
804
+ * BiDi has no command to set localStorage for an arbitrary origin, so we
805
+ * visit each origin in a throwaway top-level context whose navigation is
806
+ * fulfilled locally (never reaching the network), write the entries, and
807
+ * close it. Unlike a preload script this runs once — the application owns the
808
+ * values afterward, so its own writes survive a reload.
809
+ *
810
+ * The private context is created at the connection level (not via `newPage`),
811
+ * so it never enters `_pageIds`, the `'page'` event, routes, or active-page
812
+ * selection. The intercept is scoped to it, and navigation is forced onto
813
+ * BiDi — a Classic-initiated navigation would stall against a BiDi intercept.
763
814
  */
764
- async installLocalStoragePreload(localStorage) {
765
- // Serialize the origin entries map into the function body. Safe because
766
- // we control the JSON shape (strings only).
767
- const payload = JSON.stringify(localStorage);
768
- const fnSrc = `() => {
769
- try {
770
- const data = ${payload};
771
- const entries = data[location.origin];
772
- if (!entries) return;
773
- for (const k in entries) {
774
- try { window.localStorage.setItem(k, entries[k]); } catch (e) { /* quota / opaque origin */ }
775
- }
776
- } catch (e) { /* never break user pages */ }
777
- }`;
778
- const result = await this.conn.send('script.addPreloadScript', {
779
- functionDeclaration: fnSrc,
780
- userContexts: [this._id],
815
+ async _hydrateLocalStorage(byOrigin, errorContext) {
816
+ const origins = Object.keys(byOrigin).filter((o) => byOrigin[o] && Object.keys(byOrigin[o]).length > 0);
817
+ if (origins.length === 0)
818
+ return false;
819
+ const net = this._hooks?.getNetwork();
820
+ if (!net) {
821
+ throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'restoring localStorage requires a BiDi session with network interception', { detail: storageStateDetail(errorContext, 'setup', { section: 'localStorage' }) });
822
+ }
823
+ // Reserve the internal-context slot before the create so page tracking hides
824
+ // it even if its contextCreated event beats the create response.
825
+ this._internalCreateInFlight++;
826
+ let privId;
827
+ try {
828
+ const created = await this.conn.send('browsingContext.create', {
829
+ type: 'tab',
830
+ userContext: this._id,
831
+ });
832
+ privId = created.context;
833
+ this._internalPageIds.add(privId);
834
+ }
835
+ catch (err) {
836
+ throw this._restoreDriverError(err, errorContext, 'localStorage', false);
837
+ }
838
+ finally {
839
+ this._internalCreateInFlight--;
840
+ this._drainQuarantine();
841
+ this._resolveInternalClassificationWaiters();
842
+ }
843
+ if (!privId)
844
+ return false;
845
+ const priv = new Page(this.driver, privId, this.getDefaultTimeout, this.conn, this);
846
+ let interceptId;
847
+ let partialApplied = false;
848
+ try {
849
+ // Fulfil this private context's top-level navigations with a minimal
850
+ // document, locally — the request never reaches the network. The pattern
851
+ // is specific (a bare `**/*` does not register a working intercept): only
852
+ // this context's deterministic hydrate URL is fulfilled, and the intercept
853
+ // is scoped to `privId`, so no user page is ever touched.
854
+ interceptId = await net.intercept(`**${AUTH_STATE_HYDRATE_PATH}*`, () => ({
855
+ status: 200,
856
+ headers: { 'content-type': 'text/html' },
857
+ body: '<!doctype html><html><head></head><body></body></html>',
858
+ }), ['beforeRequestSent'], [privId], { strict: true } // never let a hydration request reach the real network
859
+ );
860
+ for (const origin of origins) {
861
+ // The path is fixed and irrelevant — the intercept fulfils it locally.
862
+ // `domcontentloaded` forces the BiDi navigate path so the intercept
863
+ // applies (a default-context Classic `load` navigation would stall).
864
+ await priv.navigateTo(`${origin}${AUTH_STATE_HYDRATE_PATH}`, {
865
+ waitUntil: 'domcontentloaded',
866
+ });
867
+ // Confirm the realm is on the requested origin (an HSTS upgrade or other
868
+ // rewrite could land elsewhere), then write the entries in one call. The
869
+ // values travel as structured string arguments — never interpolated into
870
+ // the source; the source is a string, so the DOM globals it names are the
871
+ // browser's, not Node's.
872
+ const res = await this.conn.send('script.callFunction', {
873
+ functionDeclaration: 'function (expected, json) {' +
874
+ ' if (location.origin !== expected) return "MISMATCH:" + location.origin;' +
875
+ ' var e = JSON.parse(json);' +
876
+ ' for (var k in e) localStorage.setItem(k, e[k]);' +
877
+ ' return "OK"; }',
878
+ target: { context: privId },
879
+ arguments: [
880
+ { type: 'string', value: origin },
881
+ { type: 'string', value: JSON.stringify(byOrigin[origin]) },
882
+ ],
883
+ awaitPromise: false,
884
+ });
885
+ const value = res.type === 'success' ? res.result?.value : undefined;
886
+ if (value !== 'OK') {
887
+ const mismatch = new CraftdriverError(ErrorCode.STATE_INVALID, `could not hydrate localStorage for origin ${origin}`, { detail: storageStateDetail(errorContext, 'localStorage', { origin }) });
888
+ if (!partialApplied)
889
+ throw mismatch;
890
+ throw this._restoreDriverError(mismatch, errorContext, 'localStorage', true, { origin });
891
+ }
892
+ partialApplied = true;
893
+ }
894
+ return partialApplied;
895
+ }
896
+ catch (err) {
897
+ if (isStorageStateError(err))
898
+ throw err;
899
+ throw this._restoreDriverError(err, errorContext, 'localStorage', partialApplied);
900
+ }
901
+ finally {
902
+ if (interceptId)
903
+ await net.removeIntercept(interceptId).catch(() => { });
904
+ await this.conn.send('browsingContext.close', { context: privId }).catch(() => { });
905
+ }
906
+ }
907
+ /** @internal Serialize restores without coupling independent user contexts. */
908
+ _runSerializedRestore(task) {
909
+ const run = this._restoreTail.then(task, task);
910
+ this._restoreTail = run.then(() => undefined, () => undefined);
911
+ return run;
912
+ }
913
+ /** @internal Synchronous filter for events emitted after an internal id is known. */
914
+ _isInternalPageId(id) {
915
+ return typeof id === 'string' && this._internalPageIds.has(id);
916
+ }
917
+ /** @internal Wait through the create-response race, then classify an event id. */
918
+ async _isInternalPageContext(id) {
919
+ await this._waitForInternalClassification();
920
+ return this._internalPageIds.has(id);
921
+ }
922
+ _waitForInternalClassification() {
923
+ if (this._internalCreateInFlight === 0)
924
+ return Promise.resolve();
925
+ return new Promise((resolve) => this._internalClassificationWaiters.add(resolve));
926
+ }
927
+ _resolveInternalClassificationWaiters() {
928
+ if (this._internalCreateInFlight !== 0)
929
+ return;
930
+ for (const resolve of this._internalClassificationWaiters)
931
+ resolve();
932
+ this._internalClassificationWaiters.clear();
933
+ }
934
+ _storageStateErrorContext(operation) {
935
+ return {
936
+ operation,
937
+ browserName: this._hooks?.getBrowserName?.() ?? 'unknown',
938
+ protocol: 'bidi',
939
+ };
940
+ }
941
+ _restoreDriverError(cause, context, phase, partialApplied, extra) {
942
+ return new CraftdriverError(ErrorCode.DRIVER_ERROR, `storage state restore failed while applying ${phase}`, {
943
+ cause,
944
+ detail: storageStateDetail(context, phase, { partialApplied, ...extra }),
945
+ hint: partialApplied
946
+ ? 'Restore into a fresh browser context when failure isolation is required.'
947
+ : undefined,
781
948
  });
782
- return result.script;
783
949
  }
784
950
  /**
785
951
  * Subscribe (once) to BiDi page-creation / page-destruction events and
786
952
  * seed `_pageIds` with currently-open pages in this context. Powers
787
953
  * both the `'page'` event and route membership filtering.
788
954
  */
955
+ /** Apply the normal new-page handling (tracking, headers, routes, the `'page'`
956
+ * event) for a real top-level context. */
957
+ _handleContextCreated(id) {
958
+ this._pageIds.add(id);
959
+ // Headers must be applied before the first request from this page.
960
+ this._applyExtraHeadersTo([id]).catch(() => { });
961
+ // Register every existing route on the new page.
962
+ for (const sid of this._routes.keys()) {
963
+ this._registerRouteOnPage(sid, id).catch(() => { });
964
+ }
965
+ // Notify page listeners exactly once per page.
966
+ if (this._notifiedPageIds.has(id))
967
+ return;
968
+ this._notifiedPageIds.add(id);
969
+ if (this._pageListeners.size > 0) {
970
+ const page = new Page(this.driver, id, this.getDefaultTimeout, this.conn, this);
971
+ for (const fn of this._pageListeners) {
972
+ try {
973
+ fn(page);
974
+ }
975
+ catch { /* listener errors must not break events */ }
976
+ }
977
+ }
978
+ }
979
+ /** Replay contextCreated events buffered during an internal create, once none
980
+ * is in flight — dropping any that turned out to be internal. */
981
+ _drainQuarantine() {
982
+ if (this._internalCreateInFlight > 0)
983
+ return;
984
+ const buffered = this._quarantinedCreated;
985
+ this._quarantinedCreated = [];
986
+ for (const params of buffered) {
987
+ const id = params.context;
988
+ if (this._internalPageIds.has(id))
989
+ continue; // internal → stay hidden
990
+ this._handleContextCreated(id);
991
+ }
992
+ }
789
993
  _ensurePageTracking() {
790
994
  if (this._closed)
791
995
  return Promise.resolve();
@@ -805,31 +1009,25 @@ export class BrowserContext {
805
1009
  if (params.userContext !== this._id)
806
1010
  return;
807
1011
  const id = params.context;
808
- this._pageIds.add(id);
809
- // Headers must be applied before the first request from this page.
810
- this._applyExtraHeadersTo([id]).catch(() => { });
811
- // Register every existing route on the new page.
812
- for (const sid of this._routes.keys()) {
813
- this._registerRouteOnPage(sid, id).catch(() => { });
814
- }
815
- // Notify page listeners exactly once per page.
816
- if (this._notifiedPageIds.has(id))
1012
+ // Internal hydration context — stays invisible to page tracking.
1013
+ if (this._internalPageIds.has(id))
1014
+ return;
1015
+ // Its id may not be known yet (the event can beat the create response):
1016
+ // quarantine every top-level create in this context while one is in
1017
+ // flight, then drop the internal one and replay the rest.
1018
+ if (this._internalCreateInFlight > 0) {
1019
+ this._quarantinedCreated.push(params);
817
1020
  return;
818
- this._notifiedPageIds.add(id);
819
- if (this._pageListeners.size > 0) {
820
- const page = new Page(this.driver, id, this.getDefaultTimeout, this.conn, this);
821
- for (const fn of this._pageListeners) {
822
- try {
823
- fn(page);
824
- }
825
- catch { /* listener errors must not break events */ }
826
- }
827
1021
  }
1022
+ this._handleContextCreated(id);
828
1023
  };
829
1024
  const onDestroyed = (params) => {
830
1025
  if (params.parent)
831
1026
  return;
832
1027
  const id = params.context;
1028
+ if (this._internalPageIds.has(id)) {
1029
+ return; // internal hydration context — never tracked, nothing to tear down
1030
+ }
833
1031
  this._pageIds.delete(id);
834
1032
  this._notifiedPageIds.delete(id);
835
1033
  // Tear down any per-page intercepts registered against this page.
@@ -853,8 +1051,11 @@ export class BrowserContext {
853
1051
  // miss a contextCreated event that races with this seeding.
854
1052
  try {
855
1053
  const tree = await this.conn.send('browsingContext.getTree', { maxDepth: 0 });
1054
+ await this._waitForInternalClassification();
856
1055
  for (const c of tree.contexts ?? []) {
857
- if (c.userContext === this._id && !c.parent) {
1056
+ if (c.userContext === this._id &&
1057
+ !c.parent &&
1058
+ !this._internalPageIds.has(c.context)) {
858
1059
  this._pageIds.add(c.context);
859
1060
  }
860
1061
  }