craftdriver 0.2.2 → 1.0.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/dist/cli/mcp/server.js +16 -3
  3. package/dist/cli/mcp/server.js.map +1 -1
  4. package/dist/lib/bidi/connection.d.ts +1 -1
  5. package/dist/lib/bidi/connection.d.ts.map +1 -1
  6. package/dist/lib/bidi/connection.js +3 -2
  7. package/dist/lib/bidi/connection.js.map +1 -1
  8. package/dist/lib/bidi/index.d.ts +40 -3
  9. package/dist/lib/bidi/index.d.ts.map +1 -1
  10. package/dist/lib/bidi/index.js +55 -18
  11. package/dist/lib/bidi/index.js.map +1 -1
  12. package/dist/lib/bidi/logs.d.ts.map +1 -1
  13. package/dist/lib/bidi/logs.js +7 -2
  14. package/dist/lib/bidi/logs.js.map +1 -1
  15. package/dist/lib/bidi/network.d.ts +9 -0
  16. package/dist/lib/bidi/network.d.ts.map +1 -1
  17. package/dist/lib/bidi/network.js +26 -11
  18. package/dist/lib/bidi/network.js.map +1 -1
  19. package/dist/lib/browser.d.ts +42 -4
  20. package/dist/lib/browser.d.ts.map +1 -1
  21. package/dist/lib/browser.js +140 -62
  22. package/dist/lib/browser.js.map +1 -1
  23. package/dist/lib/browserContext.d.ts +7 -2
  24. package/dist/lib/browserContext.d.ts.map +1 -1
  25. package/dist/lib/browserContext.js +15 -6
  26. package/dist/lib/browserContext.js.map +1 -1
  27. package/dist/lib/builder.d.ts +2 -1
  28. package/dist/lib/builder.d.ts.map +1 -1
  29. package/dist/lib/builder.js +35 -5
  30. package/dist/lib/builder.js.map +1 -1
  31. package/dist/lib/clickFastPath.d.ts +7 -0
  32. package/dist/lib/clickFastPath.d.ts.map +1 -0
  33. package/dist/lib/clickFastPath.js +39 -0
  34. package/dist/lib/clickFastPath.js.map +1 -0
  35. package/dist/lib/driver.d.ts.map +1 -1
  36. package/dist/lib/driver.js +21 -6
  37. package/dist/lib/driver.js.map +1 -1
  38. package/dist/lib/driverManager.d.ts +6 -0
  39. package/dist/lib/driverManager.d.ts.map +1 -1
  40. package/dist/lib/driverManager.js +97 -22
  41. package/dist/lib/driverManager.js.map +1 -1
  42. package/dist/lib/elementHandle.d.ts.map +1 -1
  43. package/dist/lib/elementHandle.js +7 -2
  44. package/dist/lib/elementHandle.js.map +1 -1
  45. package/dist/lib/expect.d.ts.map +1 -1
  46. package/dist/lib/expect.js +91 -229
  47. package/dist/lib/expect.js.map +1 -1
  48. package/dist/lib/firefox.d.ts.map +1 -1
  49. package/dist/lib/firefox.js +2 -1
  50. package/dist/lib/firefox.js.map +1 -1
  51. package/dist/lib/frame.d.ts.map +1 -1
  52. package/dist/lib/frame.js +7 -7
  53. package/dist/lib/frame.js.map +1 -1
  54. package/dist/lib/http.d.ts +7 -0
  55. package/dist/lib/http.d.ts.map +1 -1
  56. package/dist/lib/http.js +112 -3
  57. package/dist/lib/http.js.map +1 -1
  58. package/dist/lib/loadState.d.ts +23 -0
  59. package/dist/lib/loadState.d.ts.map +1 -0
  60. package/dist/lib/loadState.js +25 -0
  61. package/dist/lib/loadState.js.map +1 -0
  62. package/dist/lib/locator.d.ts +2 -0
  63. package/dist/lib/locator.d.ts.map +1 -1
  64. package/dist/lib/locator.js +19 -6
  65. package/dist/lib/locator.js.map +1 -1
  66. package/dist/lib/mouse.d.ts.map +1 -1
  67. package/dist/lib/mouse.js +2 -1
  68. package/dist/lib/mouse.js.map +1 -1
  69. package/dist/lib/page.d.ts +14 -1
  70. package/dist/lib/page.d.ts.map +1 -1
  71. package/dist/lib/page.js +66 -15
  72. package/dist/lib/page.js.map +1 -1
  73. package/dist/lib/service.d.ts +1 -0
  74. package/dist/lib/service.d.ts.map +1 -1
  75. package/dist/lib/service.js +21 -3
  76. package/dist/lib/service.js.map +1 -1
  77. package/dist/lib/timing.d.ts +104 -0
  78. package/dist/lib/timing.d.ts.map +1 -0
  79. package/dist/lib/timing.js +108 -0
  80. package/dist/lib/timing.js.map +1 -0
  81. package/dist/lib/wait.d.ts.map +1 -1
  82. package/dist/lib/wait.js +13 -6
  83. package/dist/lib/wait.js.map +1 -1
  84. package/docs/bidi-features.md +10 -2
  85. package/docs/browser-api.md +2 -1
  86. package/docs/browser-context.md +11 -14
  87. package/docs/dialogs.md +14 -3
  88. package/docs/driver-configuration.md +126 -8
  89. package/docs/emulation.md +8 -4
  90. package/docs/error-codes.md +9 -4
  91. package/docs/keyboard-mouse.md +20 -7
  92. package/docs/session-management.md +1 -1
  93. package/package.json +4 -1
  94. 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,11 +18,13 @@ 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';
23
25
  import { Clock } from './clock.js';
24
26
  import { CraftdriverError, ErrorCode } from './errors.js';
27
+ import { clickWithFastPath } from './clickFastPath.js';
25
28
  /** Common mobile device presets */
26
29
  export const devices = {
27
30
  'iPhone 14': {
@@ -180,13 +183,15 @@ export class Browser {
180
183
  _storage;
181
184
  _a11y;
182
185
  _clock;
186
+ /** Browser-scoped BiDi preload scripts registered via addInitScript()/clock. */
187
+ _browserInitScriptIds = new Set();
183
188
  _downloadsDir;
184
189
  _driverService;
185
190
  _browserName = 'chrome';
186
191
  /** Active emulation overrides, re-applied to new top-level contexts. */
187
192
  _emulation = {};
188
193
  /** Mutable browser-level defaults. Use setDefaultTimeout() / setDefaultNavigationTimeout() to change. */
189
- defaults = { timeout: 5000, navigationTimeout: 30000 };
194
+ defaults = { timeout: DEFAULT_ELEMENT_TIMEOUT_MS, navigationTimeout: DEFAULT_NAVIGATION_TIMEOUT_MS };
190
195
  constructor(driver) {
191
196
  this.driver = driver;
192
197
  this.keyboard = new Keyboard(this.driver);
@@ -208,6 +213,8 @@ export class Browser {
208
213
  }
209
214
  /** Returns the current default timeout. Used as a live getter passed to ElementHandle / expectSelector. */
210
215
  getDefaultTimeout = () => this.defaults.timeout;
216
+ hasBrowserInitScripts = () => this._browserInitScriptIds.size > 0;
217
+ hasDefaultNavigationInitScripts = () => this._defaultContext?._hasInitScriptsForNavigation() ?? this.hasBrowserInitScripts();
211
218
  static async launch(options = {}) {
212
219
  const name = options.browserName ?? 'chrome';
213
220
  const isFirefox = name === 'firefox';
@@ -225,7 +232,7 @@ export class Browser {
225
232
  let caps = {};
226
233
  if (isChromeFamily) {
227
234
  const chromeOptions = {
228
- args: isHeadless ? ['--headless=new'] : [],
235
+ args: [...(isHeadless ? ['--headless=new'] : []), ...(options.args ?? [])],
229
236
  prefs: {
230
237
  'download.default_directory': downloadsDir,
231
238
  'download.prompt_for_download': false,
@@ -265,6 +272,8 @@ export class Browser {
265
272
  const firefoxArgs = [];
266
273
  if (isHeadless)
267
274
  firefoxArgs.push('-headless');
275
+ if (options.args?.length)
276
+ firefoxArgs.push(...options.args);
268
277
  caps['moz:firefoxOptions'] = {
269
278
  args: firefoxArgs,
270
279
  prefs: {
@@ -307,6 +316,10 @@ export class Browser {
307
316
  const wsUrl = driver.__wsUrl;
308
317
  if (wsUrl && options.enableBiDi !== false) {
309
318
  await browser.initBiDi(wsUrl);
319
+ if (options.captureLogs && browser.bidiSession?.isConnected()) {
320
+ // Arm log capture now instead of lazily on first browser.logs touch.
321
+ void browser.bidiSession.logs.initialize();
322
+ }
310
323
  }
311
324
  // Load session state if provided
312
325
  if (options.storageState) {
@@ -323,17 +336,24 @@ export class Browser {
323
336
  // Retry up to 8 times with increasing delays — Firefox may not have finished
324
337
  // binding its BiDi WebSocket yet (especially when a previous session just closed
325
338
  // on the same port, or the profile is still initialising).
326
- const maxAttempts = 8;
339
+ const maxAttempts = BIDI_CONNECT_MAX_ATTEMPTS;
327
340
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
328
341
  try {
329
- await session.connect(wsUrl);
342
+ await session.connect(wsUrl, {
343
+ // Seed the top-level-context cache from the tree connect() already
344
+ // fetched, off the subscription connect() already armed — avoids a
345
+ // second getTree/subscribe round trip right after this returns.
346
+ onContextTree: (contexts) => {
347
+ this._topLevelContextTracking = this._startTopLevelContextTracking(contexts);
348
+ },
349
+ });
330
350
  await this._ensureTopLevelContextTracking().catch(() => { });
331
351
  return; // success
332
352
  }
333
353
  catch (err) {
334
354
  if (attempt < maxAttempts) {
335
355
  // Back-off: 300ms, 600ms, 900ms, 1200ms …
336
- await new Promise((r) => setTimeout(r, 300 * attempt));
356
+ await new Promise((r) => setTimeout(r, BIDI_CONNECT_BACKOFF_STEP_MS * attempt));
337
357
  }
338
358
  else {
339
359
  // All attempts failed; fall back to Classic-only mode.
@@ -432,32 +452,30 @@ export class Browser {
432
452
  async navigateTo(url, opts) {
433
453
  this._tracer?.recordAction('navigateTo', [url, opts], url);
434
454
  const waitUntil = opts?.waitUntil ?? 'load';
435
- if (this.bidiSession?.isConnected()) {
455
+ // Classic-first for ordinary `waitUntil: 'load'` navigations: Classic
456
+ // already blocks until `document.readyState === 'complete'`, so the common
457
+ // case avoids a BiDi round trip. Preload-backed sessions stay on BiDi
458
+ // because the next operation is often a BiDi script evaluation into the new
459
+ // document; mixing Classic navigate + immediate BiDi evaluate can race with
460
+ // the browser clearing old execution contexts on loaded-but-settling pages.
461
+ const needsBiDi = waitUntil !== 'load' || this.hasDefaultNavigationInitScripts();
462
+ const context = needsBiDi ? this.bidiSession?.getContext() : undefined;
463
+ if (context && this.bidiSession?.isConnected()) {
436
464
  const conn = this.bidiSession.getConnection();
437
- const context = this.bidiSession.getContext();
438
- if (!context) {
439
- // No context yet — fall through to Classic
440
- }
441
- else {
442
- // BiDi path: browsingContext.navigate with the appropriate wait
443
- // 'networkidle' uses 'complete' (load) then waits for network silence
444
- const bidiWait = waitUntil === 'none' ? 'none'
445
- : waitUntil === 'domcontentloaded' ? 'interactive'
446
- : 'complete'; // 'load' and 'networkidle'
447
- await conn.send('browsingContext.navigate', { context, url, wait: bidiWait });
448
- if (waitUntil === 'networkidle') {
449
- await this.bidiSession.network.waitForNetworkIdle({
450
- timeout: this.defaults.navigationTimeout,
451
- });
452
- }
453
- return;
465
+ const bidiWait = bidiWaitFor(waitUntil);
466
+ await conn.send('browsingContext.navigate', { context, url, wait: bidiWait });
467
+ if (waitUntil === 'networkidle') {
468
+ await this.bidiSession.network.waitForNetworkIdle({
469
+ timeout: this.defaults.navigationTimeout,
470
+ });
454
471
  }
472
+ return;
455
473
  }
456
- // Classic fallback (already waits for document.readyState === 'complete')
474
+ // Classic path default 'load', or BiDi unavailable/no context yet.
457
475
  await this.driver.navigateTo(url);
458
476
  if (waitUntil === 'networkidle') {
459
477
  // Best-effort: Classic can't track network events; give it a short settle time
460
- await new Promise(r => setTimeout(r, 500));
478
+ await new Promise(r => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
461
479
  }
462
480
  }
463
481
  /**
@@ -790,7 +808,7 @@ export class Browser {
790
808
  }
791
809
  // Classic fallback: best-effort 500ms settle
792
810
  else {
793
- await new Promise(r => setTimeout(r, 500));
811
+ await new Promise(r => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
794
812
  }
795
813
  return;
796
814
  }
@@ -835,7 +853,7 @@ export class Browser {
835
853
  const readyState = await this.driver.executeScript('return document.readyState', []);
836
854
  if (readyState === 'complete' || (target === 'interactive' && readyState === 'interactive'))
837
855
  return;
838
- await new Promise(r => setTimeout(r, 100));
856
+ await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
839
857
  }
840
858
  throw new CraftdriverError(ErrorCode.TIMEOUT_WAITING_LOAD, `waitForLoadState('${state}') timed out after ${timeout}ms`, { detail: { state, timeout } });
841
859
  }
@@ -852,10 +870,14 @@ export class Browser {
852
870
  // Silently abort any running trace so we don't leak the timer.
853
871
  if (this._tracer?.isRunning)
854
872
  this._tracer.abort();
873
+ // Whether this session had a live BiDi WebSocket. Only the Firefox+BiDi
874
+ // combination needs the port-release pause below; capture it before close().
875
+ const bidiWasActive = !!this.bidiSession;
855
876
  // Close BiDi connection first
856
877
  if (this.bidiSession) {
857
878
  await this.bidiSession.close().catch(() => { });
858
879
  }
880
+ this._browserInitScriptIds.clear();
859
881
  for (const off of this._topLevelContextTrackingOffs)
860
882
  off();
861
883
  this._topLevelContextTrackingOffs = [];
@@ -864,10 +886,16 @@ export class Browser {
864
886
  this._topLevelContextCacheVersion++;
865
887
  // DELETE the WebDriver session — this tells the driver service to close the browser.
866
888
  await this.driver.quit().catch(() => { });
867
- // Give the browser process a moment to fully release any ports (e.g. Firefox BiDi
868
- // WebSocket port 9222) before we kill the driver service. Without this pause, a
869
- // subsequent launch may see a 404 when connecting to the new session's WebSocket.
870
- await new Promise((r) => setTimeout(r, 500));
889
+ // Firefox's BiDi WebSocket binds a fixed port (9222). If we kill geckodriver
890
+ // before Firefox has released it, the next Firefox+BiDi launch can 404 while
891
+ // connecting to the new session's socket — hence this pause. Chrome has no such
892
+ // race: chromedriver assigns an ephemeral --remote-debugging-port=0 and our BiDi
893
+ // URL comes from the session's own webSocketUrl, not a fixed port. So scope the
894
+ // 500ms sleep to Firefox+BiDi; every other quit (all Chrome, Firefox w/o BiDi)
895
+ // skips straight to stopping the driver.
896
+ if (this._browserName === 'firefox' && bidiWasActive) {
897
+ await new Promise((r) => setTimeout(r, PORT_RELEASE_DELAY_MS));
898
+ }
871
899
  // Stop the underlying driver service (chromedriver / geckodriver)
872
900
  // so we don't leak processes between sessions.
873
901
  if (this._driverService) {
@@ -932,26 +960,36 @@ export class Browser {
932
960
  const fnSrc = typeof fn === 'function' ? fn.toString() : fn;
933
961
  if (this.bidiSession?.isConnected()) {
934
962
  const conn = this.bidiSession.getConnection();
935
- const context = this.bidiSession.getContext();
936
- const target = context ? { context } : {};
963
+ // Classic-first navigations return at readyState === 'complete', which is
964
+ // not a barrier the BiDi side respects: an immediately following
965
+ // { context } call can race the browser swapping the old realm for the
966
+ // new one and throw "execution contexts cleared". The error is
967
+ // pre-execution (script never ran, no side effects), so retry a few times,
968
+ // re-resolving the context each attempt. In-script errors take the
969
+ // result.type === 'exception' path below and are never retried here.
970
+ const functionDeclaration = typeof fn === 'function' ? fnSrc : `function() { ${fnSrc} }`;
971
+ const callArgs = typeof fn === 'function' ? args.map(serializeLocalValue) : [];
937
972
  let result;
938
- if (typeof fn === 'function') {
939
- result = await conn.send('script.callFunction', {
940
- functionDeclaration: fnSrc,
941
- target,
942
- arguments: args.map(serializeLocalValue),
943
- awaitPromise: true,
944
- });
945
- }
946
- else {
947
- // String form: wrap in a function so `return` statements are valid,
948
- // matching the behaviour of Classic WebDriver executeScript.
949
- result = await conn.send('script.callFunction', {
950
- functionDeclaration: `function() { ${fnSrc} }`,
951
- target,
952
- arguments: [],
953
- awaitPromise: true,
954
- });
973
+ for (let attempt = 1;; attempt++) {
974
+ const context = this.bidiSession.getContext();
975
+ const target = context ? { context } : {};
976
+ try {
977
+ result = await conn.send('script.callFunction', {
978
+ functionDeclaration,
979
+ target,
980
+ arguments: callArgs,
981
+ awaitPromise: true,
982
+ });
983
+ break;
984
+ }
985
+ catch (err) {
986
+ if (attempt < EVAL_REALM_RETRY_ATTEMPTS &&
987
+ String(err?.message).includes('execution contexts cleared')) {
988
+ await new Promise(r => setTimeout(r, EVAL_REALM_RETRY_DELAY_MS));
989
+ continue;
990
+ }
991
+ throw err;
992
+ }
955
993
  }
956
994
  if (result.type === 'exception') {
957
995
  throw new CraftdriverError(ErrorCode.EVAL_THREW, `evaluate() threw an exception in the page: ${result.exceptionDetails?.text ?? 'unknown error'}`, { detail: { exception: result.exceptionDetails?.text ?? null } });
@@ -993,9 +1031,13 @@ export class Browser {
993
1031
  functionDeclaration: fnSrc,
994
1032
  });
995
1033
  const scriptId = result.script;
1034
+ this._browserInitScriptIds.add(scriptId);
996
1035
  return {
997
1036
  remove: async () => {
1037
+ if (!this._browserInitScriptIds.has(scriptId))
1038
+ return;
998
1039
  await conn.send('script.removePreloadScript', { script: scriptId });
1040
+ this._browserInitScriptIds.delete(scriptId);
999
1041
  },
1000
1042
  };
1001
1043
  }
@@ -1072,7 +1114,7 @@ export class Browser {
1072
1114
  },
1073
1115
  };
1074
1116
  }
1075
- await new Promise(r => setTimeout(r, 100));
1117
+ await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
1076
1118
  }
1077
1119
  throw new Error(`waitForDownload() timed out after ${timeout}ms — no file appeared in ${dir}`);
1078
1120
  }
@@ -1304,8 +1346,9 @@ export class Browser {
1304
1346
  const timeout = opts?.timeout ?? this.defaults.navigationTimeout;
1305
1347
  if (this.bidiSession?.isConnected()) {
1306
1348
  const conn = this.bidiSession.getConnection();
1307
- // Subscribe to contextCreated if not already subscribed
1308
- await conn.subscribe(['browsingContext.contextCreated']).catch(() => { });
1349
+ // `browsingContext.contextCreated` is already subscribed session-wide by
1350
+ // `BiDiSession.connect()` (which must have run for `isConnected()` to be
1351
+ // true), so no per-call `subscribe()` round trip is needed here.
1309
1352
  return new Promise((resolve, reject) => {
1310
1353
  const timer = setTimeout(() => {
1311
1354
  off();
@@ -1337,7 +1380,7 @@ export class Browser {
1337
1380
  const handle = newHandles[0];
1338
1381
  return new Page(this.driver, handle, this.getDefaultTimeout);
1339
1382
  }
1340
- await new Promise(r => setTimeout(r, 100));
1383
+ await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
1341
1384
  }
1342
1385
  throw new Error(`waitForPage() timed out after ${timeout}ms — no new tab or popup appeared`);
1343
1386
  }
@@ -1389,7 +1432,11 @@ export class Browser {
1389
1432
  if (cached)
1390
1433
  return cached;
1391
1434
  const conn = this.bidiSession.getConnection();
1392
- const ctx = new BrowserContext(this.driver, conn, id, this.getDefaultTimeout, () => this.defaults.navigationTimeout, { getNetwork: () => this.network, getBrowserName: () => this._browserName }, config);
1435
+ const ctx = new BrowserContext(this.driver, conn, id, this.getDefaultTimeout, () => this.defaults.navigationTimeout, {
1436
+ getNetwork: () => this.network,
1437
+ getBrowserName: () => this._browserName,
1438
+ hasBrowserInitScripts: this.hasBrowserInitScripts,
1439
+ }, config);
1393
1440
  this._contextsById.set(id, ctx);
1394
1441
  // Evict on close so long-running suites that create many contexts don't
1395
1442
  // accumulate dead wrappers.
@@ -1458,11 +1505,32 @@ export class Browser {
1458
1505
  this._topLevelContextTracking = this._startTopLevelContextTracking();
1459
1506
  return this._topLevelContextTracking;
1460
1507
  }
1461
- async _startTopLevelContextTracking() {
1508
+ /**
1509
+ * @param initialContexts When provided (the normal path — passed via
1510
+ * `BiDiSession.connect()`'s `onContextTree` callback), `contextCreated`/
1511
+ * `contextDestroyed` are already subscribed as part of `connect()`'s
1512
+ * merged batch and the tree is already fetched — this just registers
1513
+ * handlers and seeds the cache from it, no extra round trip. Falls back
1514
+ * to its own subscribe + `getTree` when called without a pre-fetched
1515
+ * tree (defensive — e.g. if `_ensureTopLevelContextTracking()` is ever
1516
+ * reached before `initBiDi()`'s callback has run).
1517
+ */
1518
+ async _startTopLevelContextTracking(initialContexts) {
1462
1519
  const conn = this.bidiSession.getConnection();
1463
- await conn
1464
- .subscribe(['browsingContext.contextCreated', 'browsingContext.contextDestroyed'])
1465
- .catch(() => { });
1520
+ if (!initialContexts) {
1521
+ // Defensive path only. In today's sequencing this branch is unreachable:
1522
+ // `initBiDi()` always seeds `_topLevelContextTracking` via `connect()`'s
1523
+ // `onContextTree` callback (with `initialContexts`) before `launch()`
1524
+ // returns, so no external caller can reach `_ensureTopLevelContextTracking()`
1525
+ // — and thus this arg-less path — before `connect()` has already subscribed.
1526
+ // The `subscribe()` is kept (unlike the redundant ones dropped from the
1527
+ // other context-tracking call sites) precisely because this branch's
1528
+ // contract is "connect()'s subscribe may NOT have run yet"; dropping it
1529
+ // would break that contract if a future refactor made the branch reachable.
1530
+ await conn
1531
+ .subscribe(['browsingContext.contextCreated', 'browsingContext.contextDestroyed'])
1532
+ .catch(() => { });
1533
+ }
1466
1534
  const onCreated = (params) => {
1467
1535
  if (params.parent)
1468
1536
  return;
@@ -1482,7 +1550,17 @@ export class Browser {
1482
1550
  };
1483
1551
  this._topLevelContextTrackingOffs.push(conn.on('browsingContext.contextCreated', onCreated));
1484
1552
  this._topLevelContextTrackingOffs.push(conn.on('browsingContext.contextDestroyed', onDestroyed));
1485
- await this._refreshTopLevelContextCache(conn).catch(() => { });
1553
+ if (initialContexts) {
1554
+ for (const ctx of initialContexts) {
1555
+ if (!ctx.parent) {
1556
+ this._topLevelContextUserContexts.set(ctx.context, ctx.userContext ?? 'default');
1557
+ }
1558
+ }
1559
+ this._topLevelContextCacheVersion++;
1560
+ }
1561
+ else {
1562
+ await this._refreshTopLevelContextCache(conn).catch(() => { });
1563
+ }
1486
1564
  }
1487
1565
  async _refreshTopLevelContextCache(conn) {
1488
1566
  const version = this._topLevelContextCacheVersion;
@@ -1551,8 +1629,8 @@ export class Browser {
1551
1629
  if (this._tracer?.isRunning)
1552
1630
  this._tracer.recordAction('click', undefined, selectorToString(selector));
1553
1631
  const by = typeof selector === 'string' ? By.css(selector) : selector;
1554
- const el = await this.driver.wait(until.elementIsVisible(by), { timeout: opts?.timeout ?? this.defaults.timeout });
1555
- await el.click();
1632
+ const timeout = opts?.timeout ?? this.defaults.timeout;
1633
+ await clickWithFastPath(() => this.driver.findElement(by), (remaining) => this.driver.wait(until.elementIsVisible(by), { timeout: remaining }), timeout);
1556
1634
  }
1557
1635
  async fill(selector, text, opts) {
1558
1636
  if (this._tracer?.isRunning)