craftdriver 0.2.1 → 1.0.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 (84) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/lib/bidi/connection.d.ts +1 -1
  3. package/dist/lib/bidi/connection.d.ts.map +1 -1
  4. package/dist/lib/bidi/connection.js +3 -2
  5. package/dist/lib/bidi/connection.js.map +1 -1
  6. package/dist/lib/bidi/index.d.ts +40 -3
  7. package/dist/lib/bidi/index.d.ts.map +1 -1
  8. package/dist/lib/bidi/index.js +55 -18
  9. package/dist/lib/bidi/index.js.map +1 -1
  10. package/dist/lib/bidi/logs.d.ts.map +1 -1
  11. package/dist/lib/bidi/logs.js +7 -2
  12. package/dist/lib/bidi/logs.js.map +1 -1
  13. package/dist/lib/bidi/network.d.ts +9 -0
  14. package/dist/lib/bidi/network.d.ts.map +1 -1
  15. package/dist/lib/bidi/network.js +26 -11
  16. package/dist/lib/bidi/network.js.map +1 -1
  17. package/dist/lib/browser.d.ts +51 -4
  18. package/dist/lib/browser.d.ts.map +1 -1
  19. package/dist/lib/browser.js +215 -66
  20. package/dist/lib/browser.js.map +1 -1
  21. package/dist/lib/browserContext.d.ts +7 -2
  22. package/dist/lib/browserContext.d.ts.map +1 -1
  23. package/dist/lib/browserContext.js +17 -8
  24. package/dist/lib/browserContext.js.map +1 -1
  25. package/dist/lib/builder.d.ts +2 -1
  26. package/dist/lib/builder.d.ts.map +1 -1
  27. package/dist/lib/builder.js +35 -5
  28. package/dist/lib/builder.js.map +1 -1
  29. package/dist/lib/driver.d.ts.map +1 -1
  30. package/dist/lib/driver.js +21 -6
  31. package/dist/lib/driver.js.map +1 -1
  32. package/dist/lib/driverManager.d.ts +6 -0
  33. package/dist/lib/driverManager.d.ts.map +1 -1
  34. package/dist/lib/driverManager.js +97 -22
  35. package/dist/lib/driverManager.js.map +1 -1
  36. package/dist/lib/expect.d.ts.map +1 -1
  37. package/dist/lib/expect.js +91 -229
  38. package/dist/lib/expect.js.map +1 -1
  39. package/dist/lib/firefox.d.ts.map +1 -1
  40. package/dist/lib/firefox.js +2 -1
  41. package/dist/lib/firefox.js.map +1 -1
  42. package/dist/lib/frame.d.ts.map +1 -1
  43. package/dist/lib/frame.js +4 -3
  44. package/dist/lib/frame.js.map +1 -1
  45. package/dist/lib/http.d.ts +7 -0
  46. package/dist/lib/http.d.ts.map +1 -1
  47. package/dist/lib/http.js +37 -0
  48. package/dist/lib/http.js.map +1 -1
  49. package/dist/lib/loadState.d.ts +23 -0
  50. package/dist/lib/loadState.d.ts.map +1 -0
  51. package/dist/lib/loadState.js +25 -0
  52. package/dist/lib/loadState.js.map +1 -0
  53. package/dist/lib/locator.d.ts.map +1 -1
  54. package/dist/lib/locator.js +11 -4
  55. package/dist/lib/locator.js.map +1 -1
  56. package/dist/lib/mouse.d.ts.map +1 -1
  57. package/dist/lib/mouse.js +2 -1
  58. package/dist/lib/mouse.js.map +1 -1
  59. package/dist/lib/page.d.ts +14 -1
  60. package/dist/lib/page.d.ts.map +1 -1
  61. package/dist/lib/page.js +41 -9
  62. package/dist/lib/page.js.map +1 -1
  63. package/dist/lib/service.d.ts +1 -0
  64. package/dist/lib/service.d.ts.map +1 -1
  65. package/dist/lib/service.js +21 -3
  66. package/dist/lib/service.js.map +1 -1
  67. package/dist/lib/timing.d.ts +104 -0
  68. package/dist/lib/timing.d.ts.map +1 -0
  69. package/dist/lib/timing.js +108 -0
  70. package/dist/lib/timing.js.map +1 -0
  71. package/dist/lib/wait.d.ts.map +1 -1
  72. package/dist/lib/wait.js +5 -4
  73. package/dist/lib/wait.js.map +1 -1
  74. package/docs/bidi-features.md +10 -2
  75. package/docs/browser-api.md +2 -1
  76. package/docs/browser-context.md +11 -14
  77. package/docs/dialogs.md +14 -3
  78. package/docs/driver-configuration.md +126 -8
  79. package/docs/emulation.md +8 -4
  80. package/docs/error-codes.md +8 -3
  81. package/docs/keyboard-mouse.md +20 -7
  82. package/docs/session-management.md +1 -1
  83. package/package.json +4 -1
  84. package/skills/craftdriver/SKILL.md +3 -2
@@ -3,6 +3,7 @@ import { ChromeService } from './chrome.js';
3
3
  import { FirefoxService } from './firefox.js';
4
4
  import { By } from './by.js';
5
5
  import { until } from './wait.js';
6
+ import { DEFAULT_ELEMENT_TIMEOUT_MS, DEFAULT_NAVIGATION_TIMEOUT_MS, STATE_POLL_INTERVAL_MS, NETWORK_IDLE_SETTLE_MS, PORT_RELEASE_DELAY_MS, BIDI_CONNECT_MAX_ATTEMPTS, BIDI_CONNECT_BACKOFF_STEP_MS, EVAL_REALM_RETRY_ATTEMPTS, EVAL_REALM_RETRY_DELAY_MS, } from './timing.js';
6
7
  import { ElementHandle } from './elementHandle.js';
7
8
  import { Locator } from './locator.js';
8
9
  import { expectSelector } from './expect.js';
@@ -17,6 +18,7 @@ import { ActionsBuilder } from './actions.js';
17
18
  import { BiDiSession, SessionStateManager, } from './bidi/index.js';
18
19
  import { Frame } from './frame.js';
19
20
  import { Page } from './page.js';
21
+ import { bidiWaitFor } from './loadState.js';
20
22
  import { BrowserContext } from './browserContext.js';
21
23
  import { Tracer } from './tracing.js';
22
24
  import { A11y } from './a11y.js';
@@ -170,18 +172,25 @@ export class Browser {
170
172
  _defaultContext;
171
173
  /** Cache of {@link BrowserContext} wrappers keyed by user-context id. */
172
174
  _contextsById = new Map();
175
+ /** Top-level browsing-context ids keyed to their BiDi user-context id. */
176
+ _topLevelContextUserContexts = new Map();
177
+ _topLevelContextTracking;
178
+ _topLevelContextTrackingOffs = [];
179
+ _topLevelContextCacheVersion = 0;
173
180
  _logs;
174
181
  _tracer;
175
182
  _storage;
176
183
  _a11y;
177
184
  _clock;
185
+ /** Browser-scoped BiDi preload scripts registered via addInitScript()/clock. */
186
+ _browserInitScriptIds = new Set();
178
187
  _downloadsDir;
179
188
  _driverService;
180
189
  _browserName = 'chrome';
181
190
  /** Active emulation overrides, re-applied to new top-level contexts. */
182
191
  _emulation = {};
183
192
  /** Mutable browser-level defaults. Use setDefaultTimeout() / setDefaultNavigationTimeout() to change. */
184
- defaults = { timeout: 5000, navigationTimeout: 30000 };
193
+ defaults = { timeout: DEFAULT_ELEMENT_TIMEOUT_MS, navigationTimeout: DEFAULT_NAVIGATION_TIMEOUT_MS };
185
194
  constructor(driver) {
186
195
  this.driver = driver;
187
196
  this.keyboard = new Keyboard(this.driver);
@@ -203,6 +212,8 @@ export class Browser {
203
212
  }
204
213
  /** Returns the current default timeout. Used as a live getter passed to ElementHandle / expectSelector. */
205
214
  getDefaultTimeout = () => this.defaults.timeout;
215
+ hasBrowserInitScripts = () => this._browserInitScriptIds.size > 0;
216
+ hasDefaultNavigationInitScripts = () => this._defaultContext?._hasInitScriptsForNavigation() ?? this.hasBrowserInitScripts();
206
217
  static async launch(options = {}) {
207
218
  const name = options.browserName ?? 'chrome';
208
219
  const isFirefox = name === 'firefox';
@@ -220,7 +231,7 @@ export class Browser {
220
231
  let caps = {};
221
232
  if (isChromeFamily) {
222
233
  const chromeOptions = {
223
- args: isHeadless ? ['--headless=new'] : [],
234
+ args: [...(isHeadless ? ['--headless=new'] : []), ...(options.args ?? [])],
224
235
  prefs: {
225
236
  'download.default_directory': downloadsDir,
226
237
  'download.prompt_for_download': false,
@@ -260,6 +271,8 @@ export class Browser {
260
271
  const firefoxArgs = [];
261
272
  if (isHeadless)
262
273
  firefoxArgs.push('-headless');
274
+ if (options.args?.length)
275
+ firefoxArgs.push(...options.args);
263
276
  caps['moz:firefoxOptions'] = {
264
277
  args: firefoxArgs,
265
278
  prefs: {
@@ -302,6 +315,10 @@ export class Browser {
302
315
  const wsUrl = driver.__wsUrl;
303
316
  if (wsUrl && options.enableBiDi !== false) {
304
317
  await browser.initBiDi(wsUrl);
318
+ if (options.captureLogs && browser.bidiSession?.isConnected()) {
319
+ // Arm log capture now instead of lazily on first browser.logs touch.
320
+ void browser.bidiSession.logs.initialize();
321
+ }
305
322
  }
306
323
  // Load session state if provided
307
324
  if (options.storageState) {
@@ -318,16 +335,24 @@ export class Browser {
318
335
  // Retry up to 8 times with increasing delays — Firefox may not have finished
319
336
  // binding its BiDi WebSocket yet (especially when a previous session just closed
320
337
  // on the same port, or the profile is still initialising).
321
- const maxAttempts = 8;
338
+ const maxAttempts = BIDI_CONNECT_MAX_ATTEMPTS;
322
339
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
323
340
  try {
324
- await session.connect(wsUrl);
341
+ await session.connect(wsUrl, {
342
+ // Seed the top-level-context cache from the tree connect() already
343
+ // fetched, off the subscription connect() already armed — avoids a
344
+ // second getTree/subscribe round trip right after this returns.
345
+ onContextTree: (contexts) => {
346
+ this._topLevelContextTracking = this._startTopLevelContextTracking(contexts);
347
+ },
348
+ });
349
+ await this._ensureTopLevelContextTracking().catch(() => { });
325
350
  return; // success
326
351
  }
327
352
  catch (err) {
328
353
  if (attempt < maxAttempts) {
329
354
  // Back-off: 300ms, 600ms, 900ms, 1200ms …
330
- await new Promise((r) => setTimeout(r, 300 * attempt));
355
+ await new Promise((r) => setTimeout(r, BIDI_CONNECT_BACKOFF_STEP_MS * attempt));
331
356
  }
332
357
  else {
333
358
  // All attempts failed; fall back to Classic-only mode.
@@ -426,32 +451,30 @@ export class Browser {
426
451
  async navigateTo(url, opts) {
427
452
  this._tracer?.recordAction('navigateTo', [url, opts], url);
428
453
  const waitUntil = opts?.waitUntil ?? 'load';
429
- if (this.bidiSession?.isConnected()) {
454
+ // Classic-first for ordinary `waitUntil: 'load'` navigations: Classic
455
+ // already blocks until `document.readyState === 'complete'`, so the common
456
+ // case avoids a BiDi round trip. Preload-backed sessions stay on BiDi
457
+ // because the next operation is often a BiDi script evaluation into the new
458
+ // document; mixing Classic navigate + immediate BiDi evaluate can race with
459
+ // the browser clearing old execution contexts on loaded-but-settling pages.
460
+ const needsBiDi = waitUntil !== 'load' || this.hasDefaultNavigationInitScripts();
461
+ const context = needsBiDi ? this.bidiSession?.getContext() : undefined;
462
+ if (context && this.bidiSession?.isConnected()) {
430
463
  const conn = this.bidiSession.getConnection();
431
- const context = this.bidiSession.getContext();
432
- if (!context) {
433
- // No context yet — fall through to Classic
434
- }
435
- else {
436
- // BiDi path: browsingContext.navigate with the appropriate wait
437
- // 'networkidle' uses 'complete' (load) then waits for network silence
438
- const bidiWait = waitUntil === 'none' ? 'none'
439
- : waitUntil === 'domcontentloaded' ? 'interactive'
440
- : 'complete'; // 'load' and 'networkidle'
441
- await conn.send('browsingContext.navigate', { context, url, wait: bidiWait });
442
- if (waitUntil === 'networkidle') {
443
- await this.bidiSession.network.waitForNetworkIdle({
444
- timeout: this.defaults.navigationTimeout,
445
- });
446
- }
447
- return;
464
+ const bidiWait = bidiWaitFor(waitUntil);
465
+ await conn.send('browsingContext.navigate', { context, url, wait: bidiWait });
466
+ if (waitUntil === 'networkidle') {
467
+ await this.bidiSession.network.waitForNetworkIdle({
468
+ timeout: this.defaults.navigationTimeout,
469
+ });
448
470
  }
471
+ return;
449
472
  }
450
- // Classic fallback (already waits for document.readyState === 'complete')
473
+ // Classic path default 'load', or BiDi unavailable/no context yet.
451
474
  await this.driver.navigateTo(url);
452
475
  if (waitUntil === 'networkidle') {
453
476
  // Best-effort: Classic can't track network events; give it a short settle time
454
- await new Promise(r => setTimeout(r, 500));
477
+ await new Promise(r => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
455
478
  }
456
479
  }
457
480
  /**
@@ -598,7 +621,7 @@ export class Browser {
598
621
  }
599
622
  const conn = this.bidiSession.getConnection();
600
623
  // Apply across all top-level contexts in the default user context.
601
- const tree = await conn.send('browsingContext.getTree', {});
624
+ const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
602
625
  const tops = (tree.contexts ?? []).filter((c) => !c.parent);
603
626
  const contexts = tops.map((c) => c.context);
604
627
  const params = { contexts };
@@ -679,7 +702,7 @@ export class Browser {
679
702
  }
680
703
  }
681
704
  const conn = this.bidiSession.getConnection();
682
- const tree = await conn.send('browsingContext.getTree', {});
705
+ const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
683
706
  const contexts = (tree.contexts ?? []).filter((c) => !c.parent).map((c) => c.context);
684
707
  // Merge the new options into the persisted state so subsequent pages inherit.
685
708
  this._emulation = { ...this._emulation, ...options };
@@ -784,7 +807,7 @@ export class Browser {
784
807
  }
785
808
  // Classic fallback: best-effort 500ms settle
786
809
  else {
787
- await new Promise(r => setTimeout(r, 500));
810
+ await new Promise(r => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
788
811
  }
789
812
  return;
790
813
  }
@@ -829,7 +852,7 @@ export class Browser {
829
852
  const readyState = await this.driver.executeScript('return document.readyState', []);
830
853
  if (readyState === 'complete' || (target === 'interactive' && readyState === 'interactive'))
831
854
  return;
832
- await new Promise(r => setTimeout(r, 100));
855
+ await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
833
856
  }
834
857
  throw new CraftdriverError(ErrorCode.TIMEOUT_WAITING_LOAD, `waitForLoadState('${state}') timed out after ${timeout}ms`, { detail: { state, timeout } });
835
858
  }
@@ -846,16 +869,32 @@ export class Browser {
846
869
  // Silently abort any running trace so we don't leak the timer.
847
870
  if (this._tracer?.isRunning)
848
871
  this._tracer.abort();
872
+ // Whether this session had a live BiDi WebSocket. Only the Firefox+BiDi
873
+ // combination needs the port-release pause below; capture it before close().
874
+ const bidiWasActive = !!this.bidiSession;
849
875
  // Close BiDi connection first
850
876
  if (this.bidiSession) {
851
877
  await this.bidiSession.close().catch(() => { });
852
878
  }
879
+ this._browserInitScriptIds.clear();
880
+ for (const off of this._topLevelContextTrackingOffs)
881
+ off();
882
+ this._topLevelContextTrackingOffs = [];
883
+ this._topLevelContextTracking = undefined;
884
+ this._topLevelContextUserContexts.clear();
885
+ this._topLevelContextCacheVersion++;
853
886
  // DELETE the WebDriver session — this tells the driver service to close the browser.
854
887
  await this.driver.quit().catch(() => { });
855
- // Give the browser process a moment to fully release any ports (e.g. Firefox BiDi
856
- // WebSocket port 9222) before we kill the driver service. Without this pause, a
857
- // subsequent launch may see a 404 when connecting to the new session's WebSocket.
858
- await new Promise((r) => setTimeout(r, 500));
888
+ // Firefox's BiDi WebSocket binds a fixed port (9222). If we kill geckodriver
889
+ // before Firefox has released it, the next Firefox+BiDi launch can 404 while
890
+ // connecting to the new session's socket — hence this pause. Chrome has no such
891
+ // race: chromedriver assigns an ephemeral --remote-debugging-port=0 and our BiDi
892
+ // URL comes from the session's own webSocketUrl, not a fixed port. So scope the
893
+ // 500ms sleep to Firefox+BiDi; every other quit (all Chrome, Firefox w/o BiDi)
894
+ // skips straight to stopping the driver.
895
+ if (this._browserName === 'firefox' && bidiWasActive) {
896
+ await new Promise((r) => setTimeout(r, PORT_RELEASE_DELAY_MS));
897
+ }
859
898
  // Stop the underlying driver service (chromedriver / geckodriver)
860
899
  // so we don't leak processes between sessions.
861
900
  if (this._driverService) {
@@ -920,26 +959,36 @@ export class Browser {
920
959
  const fnSrc = typeof fn === 'function' ? fn.toString() : fn;
921
960
  if (this.bidiSession?.isConnected()) {
922
961
  const conn = this.bidiSession.getConnection();
923
- const context = this.bidiSession.getContext();
924
- const target = context ? { context } : {};
962
+ // Classic-first navigations return at readyState === 'complete', which is
963
+ // not a barrier the BiDi side respects: an immediately following
964
+ // { context } call can race the browser swapping the old realm for the
965
+ // new one and throw "execution contexts cleared". The error is
966
+ // pre-execution (script never ran, no side effects), so retry a few times,
967
+ // re-resolving the context each attempt. In-script errors take the
968
+ // result.type === 'exception' path below and are never retried here.
969
+ const functionDeclaration = typeof fn === 'function' ? fnSrc : `function() { ${fnSrc} }`;
970
+ const callArgs = typeof fn === 'function' ? args.map(serializeLocalValue) : [];
925
971
  let result;
926
- if (typeof fn === 'function') {
927
- result = await conn.send('script.callFunction', {
928
- functionDeclaration: fnSrc,
929
- target,
930
- arguments: args.map(serializeLocalValue),
931
- awaitPromise: true,
932
- });
933
- }
934
- else {
935
- // String form: wrap in a function so `return` statements are valid,
936
- // matching the behaviour of Classic WebDriver executeScript.
937
- result = await conn.send('script.callFunction', {
938
- functionDeclaration: `function() { ${fnSrc} }`,
939
- target,
940
- arguments: [],
941
- awaitPromise: true,
942
- });
972
+ for (let attempt = 1;; attempt++) {
973
+ const context = this.bidiSession.getContext();
974
+ const target = context ? { context } : {};
975
+ try {
976
+ result = await conn.send('script.callFunction', {
977
+ functionDeclaration,
978
+ target,
979
+ arguments: callArgs,
980
+ awaitPromise: true,
981
+ });
982
+ break;
983
+ }
984
+ catch (err) {
985
+ if (attempt < EVAL_REALM_RETRY_ATTEMPTS &&
986
+ String(err?.message).includes('execution contexts cleared')) {
987
+ await new Promise(r => setTimeout(r, EVAL_REALM_RETRY_DELAY_MS));
988
+ continue;
989
+ }
990
+ throw err;
991
+ }
943
992
  }
944
993
  if (result.type === 'exception') {
945
994
  throw new CraftdriverError(ErrorCode.EVAL_THREW, `evaluate() threw an exception in the page: ${result.exceptionDetails?.text ?? 'unknown error'}`, { detail: { exception: result.exceptionDetails?.text ?? null } });
@@ -981,9 +1030,13 @@ export class Browser {
981
1030
  functionDeclaration: fnSrc,
982
1031
  });
983
1032
  const scriptId = result.script;
1033
+ this._browserInitScriptIds.add(scriptId);
984
1034
  return {
985
1035
  remove: async () => {
1036
+ if (!this._browserInitScriptIds.has(scriptId))
1037
+ return;
986
1038
  await conn.send('script.removePreloadScript', { script: scriptId });
1039
+ this._browserInitScriptIds.delete(scriptId);
987
1040
  },
988
1041
  };
989
1042
  }
@@ -1060,7 +1113,7 @@ export class Browser {
1060
1113
  },
1061
1114
  };
1062
1115
  }
1063
- await new Promise(r => setTimeout(r, 100));
1116
+ await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
1064
1117
  }
1065
1118
  throw new Error(`waitForDownload() timed out after ${timeout}ms — no file appeared in ${dir}`);
1066
1119
  }
@@ -1242,7 +1295,7 @@ export class Browser {
1242
1295
  async pages() {
1243
1296
  if (this.bidiSession?.isConnected()) {
1244
1297
  const conn = this.bidiSession.getConnection();
1245
- const tree = await conn.send('browsingContext.getTree', {});
1298
+ const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
1246
1299
  return (tree.contexts ?? []).map((ctx) => new Page(this.driver, ctx.context, this.getDefaultTimeout, conn, this._wrapContext(ctx.userContext ?? 'default')));
1247
1300
  }
1248
1301
  // Classic fallback: use window handles
@@ -1292,8 +1345,9 @@ export class Browser {
1292
1345
  const timeout = opts?.timeout ?? this.defaults.navigationTimeout;
1293
1346
  if (this.bidiSession?.isConnected()) {
1294
1347
  const conn = this.bidiSession.getConnection();
1295
- // Subscribe to contextCreated if not already subscribed
1296
- await conn.subscribe(['browsingContext.contextCreated']).catch(() => { });
1348
+ // `browsingContext.contextCreated` is already subscribed session-wide by
1349
+ // `BiDiSession.connect()` (which must have run for `isConnected()` to be
1350
+ // true), so no per-call `subscribe()` round trip is needed here.
1297
1351
  return new Promise((resolve, reject) => {
1298
1352
  const timer = setTimeout(() => {
1299
1353
  off();
@@ -1325,7 +1379,7 @@ export class Browser {
1325
1379
  const handle = newHandles[0];
1326
1380
  return new Page(this.driver, handle, this.getDefaultTimeout);
1327
1381
  }
1328
- await new Promise(r => setTimeout(r, 100));
1382
+ await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
1329
1383
  }
1330
1384
  throw new Error(`waitForPage() timed out after ${timeout}ms — no new tab or popup appeared`);
1331
1385
  }
@@ -1377,7 +1431,11 @@ export class Browser {
1377
1431
  if (cached)
1378
1432
  return cached;
1379
1433
  const conn = this.bidiSession.getConnection();
1380
- const ctx = new BrowserContext(this.driver, conn, id, this.getDefaultTimeout, () => this.defaults.navigationTimeout, { getNetwork: () => this.network, getBrowserName: () => this._browserName }, config);
1434
+ const ctx = new BrowserContext(this.driver, conn, id, this.getDefaultTimeout, () => this.defaults.navigationTimeout, {
1435
+ getNetwork: () => this.network,
1436
+ getBrowserName: () => this._browserName,
1437
+ hasBrowserInitScripts: this.hasBrowserInitScripts,
1438
+ }, config);
1381
1439
  this._contextsById.set(id, ctx);
1382
1440
  // Evict on close so long-running suites that create many contexts don't
1383
1441
  // accumulate dead wrappers.
@@ -1438,6 +1496,91 @@ export class Browser {
1438
1496
  this._defaultContext = this._wrapContext('default');
1439
1497
  return this._defaultContext;
1440
1498
  }
1499
+ async _ensureTopLevelContextTracking() {
1500
+ if (!this.bidiSession?.isConnected())
1501
+ return;
1502
+ if (this._topLevelContextTracking)
1503
+ return this._topLevelContextTracking;
1504
+ this._topLevelContextTracking = this._startTopLevelContextTracking();
1505
+ return this._topLevelContextTracking;
1506
+ }
1507
+ /**
1508
+ * @param initialContexts When provided (the normal path — passed via
1509
+ * `BiDiSession.connect()`'s `onContextTree` callback), `contextCreated`/
1510
+ * `contextDestroyed` are already subscribed as part of `connect()`'s
1511
+ * merged batch and the tree is already fetched — this just registers
1512
+ * handlers and seeds the cache from it, no extra round trip. Falls back
1513
+ * to its own subscribe + `getTree` when called without a pre-fetched
1514
+ * tree (defensive — e.g. if `_ensureTopLevelContextTracking()` is ever
1515
+ * reached before `initBiDi()`'s callback has run).
1516
+ */
1517
+ async _startTopLevelContextTracking(initialContexts) {
1518
+ const conn = this.bidiSession.getConnection();
1519
+ if (!initialContexts) {
1520
+ // Defensive path only. In today's sequencing this branch is unreachable:
1521
+ // `initBiDi()` always seeds `_topLevelContextTracking` via `connect()`'s
1522
+ // `onContextTree` callback (with `initialContexts`) before `launch()`
1523
+ // returns, so no external caller can reach `_ensureTopLevelContextTracking()`
1524
+ // — and thus this arg-less path — before `connect()` has already subscribed.
1525
+ // The `subscribe()` is kept (unlike the redundant ones dropped from the
1526
+ // other context-tracking call sites) precisely because this branch's
1527
+ // contract is "connect()'s subscribe may NOT have run yet"; dropping it
1528
+ // would break that contract if a future refactor made the branch reachable.
1529
+ await conn
1530
+ .subscribe(['browsingContext.contextCreated', 'browsingContext.contextDestroyed'])
1531
+ .catch(() => { });
1532
+ }
1533
+ const onCreated = (params) => {
1534
+ if (params.parent)
1535
+ return;
1536
+ const id = params.context;
1537
+ if (typeof id !== 'string')
1538
+ return;
1539
+ this._topLevelContextUserContexts.set(id, typeof params.userContext === 'string' ? params.userContext : '');
1540
+ this._topLevelContextCacheVersion++;
1541
+ };
1542
+ const onDestroyed = (params) => {
1543
+ if (params.parent)
1544
+ return;
1545
+ if (typeof params.context === 'string') {
1546
+ this._topLevelContextUserContexts.delete(params.context);
1547
+ this._topLevelContextCacheVersion++;
1548
+ }
1549
+ };
1550
+ this._topLevelContextTrackingOffs.push(conn.on('browsingContext.contextCreated', onCreated));
1551
+ this._topLevelContextTrackingOffs.push(conn.on('browsingContext.contextDestroyed', onDestroyed));
1552
+ if (initialContexts) {
1553
+ for (const ctx of initialContexts) {
1554
+ if (!ctx.parent) {
1555
+ this._topLevelContextUserContexts.set(ctx.context, ctx.userContext ?? 'default');
1556
+ }
1557
+ }
1558
+ this._topLevelContextCacheVersion++;
1559
+ }
1560
+ else {
1561
+ await this._refreshTopLevelContextCache(conn).catch(() => { });
1562
+ }
1563
+ }
1564
+ async _refreshTopLevelContextCache(conn) {
1565
+ const version = this._topLevelContextCacheVersion;
1566
+ const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
1567
+ if (this._topLevelContextCacheVersion === version) {
1568
+ this._topLevelContextUserContexts.clear();
1569
+ }
1570
+ for (const ctx of tree.contexts ?? []) {
1571
+ if (!ctx.parent) {
1572
+ this._topLevelContextUserContexts.set(ctx.context, ctx.userContext ?? 'default');
1573
+ }
1574
+ }
1575
+ this._topLevelContextCacheVersion++;
1576
+ }
1577
+ _firstDefaultTopLevelContext() {
1578
+ for (const [id, userContext] of this._topLevelContextUserContexts) {
1579
+ if (userContext === 'default')
1580
+ return id;
1581
+ }
1582
+ return undefined;
1583
+ }
1441
1584
  /**
1442
1585
  * Return the currently focused top-level page in `defaultContext`.
1443
1586
  *
@@ -1460,16 +1603,22 @@ export class Browser {
1460
1603
  async activePage() {
1461
1604
  if (this.bidiSession?.isConnected()) {
1462
1605
  const conn = this.bidiSession.getConnection();
1463
- const tree = await conn.send('browsingContext.getTree', {});
1464
- const tops = (tree.contexts ?? []).filter((c) => !c.parent && c.userContext === 'default');
1465
- if (tops.length === 0) {
1606
+ const handle = await this.driver.getCurrentWindowHandle().catch(() => '');
1607
+ await this._ensureTopLevelContextTracking();
1608
+ if (handle && this._topLevelContextUserContexts.get(handle) === 'default') {
1609
+ return new Page(this.driver, handle, this.getDefaultTimeout, conn, this.defaultContext);
1610
+ }
1611
+ // Events should keep the cache warm, but a cheap top-level sync covers
1612
+ // missed startup events and direct protocol/browser changes.
1613
+ await this._refreshTopLevelContextCache(conn);
1614
+ if (handle && this._topLevelContextUserContexts.get(handle) === 'default') {
1615
+ return new Page(this.driver, handle, this.getDefaultTimeout, conn, this.defaultContext);
1616
+ }
1617
+ const fallback = this._firstDefaultTopLevelContext();
1618
+ if (!fallback) {
1466
1619
  throw new Error('activePage(): no top-level page in the default context.');
1467
1620
  }
1468
- // Match the Classic-driver focus to disambiguate when multiple top-level
1469
- // pages exist in the default context (e.g. after browser.openPage()).
1470
- const handle = await this.driver.getCurrentWindowHandle().catch(() => '');
1471
- const focused = tops.find((c) => c.context === handle) ?? tops[0];
1472
- return new Page(this.driver, focused.context, this.getDefaultTimeout, conn, this.defaultContext);
1621
+ return new Page(this.driver, fallback, this.getDefaultTimeout, conn, this.defaultContext);
1473
1622
  }
1474
1623
  // Classic mode: no user-context concept; just wrap the current window.
1475
1624
  const handle = await this.driver.getCurrentWindowHandle();