browser-commander 0.26.3 → 0.28.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 (115) hide show
  1. package/.jscpd-baseline.json +24 -23
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +6 -0
  4. package/eslint.config.js +11 -0
  5. package/package.json +9 -4
  6. package/src/bindings.js +19 -0
  7. package/src/browser/attach/snapshot-launch.js +8 -6
  8. package/src/browser/attach/snapshot.js +39 -2
  9. package/src/browser/browser-cookie-database.js +71 -2
  10. package/src/browser/browser-cookie-session.js +183 -0
  11. package/src/browser/browser-cookies.js +29 -0
  12. package/src/browser/connector.js +117 -13
  13. package/src/browser/debugging-port.js +4 -2
  14. package/src/browser/default-browser.js +33 -9
  15. package/src/browser/launch-diagnostics.js +102 -0
  16. package/src/browser/launch-options.js +2 -1
  17. package/src/browser/launch-restrictions.json +33 -2
  18. package/src/browser/launcher.js +54 -9
  19. package/src/browser/migration/sqlite-snapshot.js +5 -27
  20. package/src/browser/navigation.js +173 -98
  21. package/src/browser/persistent-session.js +360 -0
  22. package/src/browser/profile-directory.js +20 -0
  23. package/src/browser/real-browser.js +142 -70
  24. package/src/browser/restrictions.js +13 -4
  25. package/src/browser/session-cookies.js +78 -0
  26. package/src/browser/session-persistence.js +72 -0
  27. package/src/browser/session-watchdog.js +71 -0
  28. package/src/browser/session-worker.js +70 -0
  29. package/src/browser/storage-state.js +32 -6
  30. package/src/browser/webdriver.js +4 -0
  31. package/src/capture/encoding.js +327 -0
  32. package/src/capture/errors.js +12 -0
  33. package/src/capture/files.js +9 -0
  34. package/src/capture/index.js +5 -0
  35. package/src/capture/recording.js +136 -0
  36. package/src/capture/screenshot.js +222 -0
  37. package/src/capture/video.js +186 -0
  38. package/src/capture/viewport.js +68 -0
  39. package/src/capture/worker.js +22 -0
  40. package/src/cli/args.js +66 -1
  41. package/src/cli/commands.js +113 -2
  42. package/src/cli/high-level-methods.js +40 -3
  43. package/src/cli/sessions.js +11 -3
  44. package/src/core/commander-features.js +105 -0
  45. package/src/core/constants.js +1 -0
  46. package/src/core/dialog-manager.js +7 -0
  47. package/src/core/managed-navigation.js +101 -0
  48. package/src/core/navigation-manager.js +66 -80
  49. package/src/core/navigation-operation.js +25 -0
  50. package/src/core/navigation-readiness.js +68 -16
  51. package/src/core/navigation-safety.js +51 -0
  52. package/src/core/network-tracker.js +39 -92
  53. package/src/core/page-session.js +3 -2
  54. package/src/core/page-trigger-manager.js +130 -42
  55. package/src/core/readiness.js +76 -14
  56. package/src/core/subscriptions.js +20 -0
  57. package/src/core/webdriver-page.js +38 -0
  58. package/src/elements/locators.js +61 -37
  59. package/src/elements/reusable.js +92 -0
  60. package/src/elements/selectors.js +37 -81
  61. package/src/elements/visibility.js +38 -21
  62. package/src/exports.js +14 -0
  63. package/src/factory.js +17 -16
  64. package/src/high-level/universal-logic.js +57 -21
  65. package/src/index.js +2 -0
  66. package/src/interactions/click.js +47 -0
  67. package/src/interactions/overlays.js +28 -0
  68. package/src/interactions/scroll.js +8 -2
  69. package/src/traces/bundle.js +14 -5
  70. package/src/traces/checkpoints.js +99 -0
  71. package/src/traces/links.js +117 -1
  72. package/src/traces/mutation-stream.js +1 -0
  73. package/src/traces/network.js +257 -0
  74. package/src/traces/page-capture.js +143 -52
  75. package/src/traces/reader.js +8 -10
  76. package/src/traces/recorder.js +221 -245
  77. package/src/traces/render.js +168 -0
  78. package/src/traces/rolling.js +180 -0
  79. package/src/traces/start-cleanup.js +15 -0
  80. package/src/traces/stop.js +159 -0
  81. package/src/traces/storage.js +43 -0
  82. package/src/utilities/subprocess.js +47 -4
  83. package/tests/e2e/cli.e2e.test.js +20 -1
  84. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  85. package/tests/e2e/navigation-budget.e2e.test.js +132 -0
  86. package/tests/e2e/reusable-session.e2e.test.js +231 -0
  87. package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
  88. package/tests/unit/browser/attach/snapshot.test.js +33 -0
  89. package/tests/unit/browser/default-browser.test.js +18 -0
  90. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  91. package/tests/unit/browser/launch-diagnostics.test.js +52 -0
  92. package/tests/unit/browser/launcher.test.js +5 -1
  93. package/tests/unit/browser/persistent-session.test.js +59 -0
  94. package/tests/unit/browser/quiet-ui.test.js +16 -0
  95. package/tests/unit/browser/real-browser.test.js +1 -1
  96. package/tests/unit/browser/session-workflows.test.js +206 -0
  97. package/tests/unit/browser/storage-state.test.js +7 -2
  98. package/tests/unit/browser/tab-selection.test.js +38 -0
  99. package/tests/unit/capture/capture.test.js +52 -0
  100. package/tests/unit/capture/cleanup.test.js +24 -0
  101. package/tests/unit/capture/viewport.test.js +168 -0
  102. package/tests/unit/cli/dispatcher.test.js +25 -0
  103. package/tests/unit/core/navigation-budget.test.js +120 -0
  104. package/tests/unit/core/navigation-manager.test.js +23 -1
  105. package/tests/unit/core/readiness.test.js +21 -0
  106. package/tests/unit/core/trigger-readiness.test.js +39 -0
  107. package/tests/unit/core/webdriver-page.test.js +33 -0
  108. package/tests/unit/elements/reusable-helpers.test.js +83 -0
  109. package/tests/unit/elements/visible-count.test.js +24 -0
  110. package/tests/unit/elements/wait-navigation.test.js +26 -0
  111. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  112. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  113. package/tests/unit/traces/bundle.test.js +16 -0
  114. package/tests/unit/traces/links.test.js +2 -2
  115. package/tests/unit/traces/network.test.js +97 -0
@@ -1,3 +1,5 @@
1
+ import { subscribeCallbacks } from './subscriptions.js';
2
+
1
3
  /**
2
4
  * PageTriggerManager - Manages stoppable page triggers with proper lifecycle
3
5
  *
@@ -168,7 +170,8 @@ export function createPageTriggerManager(options = {}) {
168
170
  }
169
171
 
170
172
  // Registered triggers
171
- const triggers = [];
173
+ const triggers = [],
174
+ subscriptions = [];
172
175
 
173
176
  // Current action state
174
177
  let currentTrigger = null;
@@ -176,8 +179,9 @@ export function createPageTriggerManager(options = {}) {
176
179
  let actionPromise = null;
177
180
  let actionStopPromise = null;
178
181
  let actionStopResolve = null;
179
- let isActionRunning = false;
180
- let isStopping = false;
182
+ let isActionRunning = false,
183
+ isStopping = false;
184
+ let startQueue = Promise.resolve();
181
185
 
182
186
  /**
183
187
  * Register a page trigger
@@ -189,7 +193,24 @@ export function createPageTriggerManager(options = {}) {
189
193
  * @returns {Function} - Unregister function
190
194
  */
191
195
  function pageTrigger(config) {
192
- const { condition, action, name = 'unnamed', priority = 0 } = config;
196
+ const {
197
+ condition,
198
+ action,
199
+ name = 'unnamed',
200
+ priority = 0,
201
+ readyOn = 'networkidle',
202
+ concurrency = 'skip',
203
+ } = config;
204
+ if (
205
+ !['urlchange', 'domcontentloaded', 'load', 'networkidle'].includes(
206
+ readyOn
207
+ )
208
+ ) {
209
+ throw new RangeError(`Unknown trigger readyOn: ${readyOn}`);
210
+ }
211
+ if (!['skip', 'restart'].includes(concurrency)) {
212
+ throw new RangeError(`Unknown trigger concurrency: ${concurrency}`);
213
+ }
193
214
 
194
215
  if (typeof condition !== 'function') {
195
216
  throw new Error('condition must be a function');
@@ -203,6 +224,8 @@ export function createPageTriggerManager(options = {}) {
203
224
  action,
204
225
  name,
205
226
  priority,
227
+ readyOn,
228
+ concurrency,
206
229
  };
207
230
 
208
231
  triggers.push(triggerConfig);
@@ -232,7 +255,7 @@ export function createPageTriggerManager(options = {}) {
232
255
  function findMatchingTrigger(ctx) {
233
256
  for (const config of triggers) {
234
257
  try {
235
- if (config.condition(ctx)) {
258
+ if (config.readyOn === ctx.readyOn && config.condition(ctx)) {
236
259
  return config;
237
260
  }
238
261
  } catch (e) {
@@ -276,8 +299,9 @@ export function createPageTriggerManager(options = {}) {
276
299
 
277
300
  // Wait for action to finish (with timeout)
278
301
  const timeoutMs = 10000; // 10 second max wait
302
+ let stopTimer;
279
303
  const timeoutPromise = new Promise((resolve) => {
280
- setTimeout(() => {
304
+ stopTimer = setTimeout(() => {
281
305
  log.debug(
282
306
  () =>
283
307
  `⚠️ Action "${currentTrigger?.name}" did not stop gracefully within ${timeoutMs}ms`
@@ -287,6 +311,16 @@ export function createPageTriggerManager(options = {}) {
287
311
  });
288
312
 
289
313
  await Promise.race([actionPromise, timeoutPromise]);
314
+ clearTimeout(stopTimer);
315
+ // An action can ignore cancellation. Keep it in flight until it settles,
316
+ // so a second form handler can never race it after the grace period.
317
+ if (isActionRunning) {
318
+ isStopping = false;
319
+ actionStopResolve?.();
320
+ actionStopPromise = null;
321
+ actionStopResolve = null;
322
+ return;
323
+ }
290
324
 
291
325
  // Cleanup
292
326
  isActionRunning = false;
@@ -310,11 +344,12 @@ export function createPageTriggerManager(options = {}) {
310
344
  * @param {string} url - URL to start action for
311
345
  * @param {Object} commander - BrowserCommander instance
312
346
  */
313
- function startAction(url, commander) {
347
+ function startAction(url, commander, readyOn = 'networkidle') {
314
348
  // Create context for condition checking
315
349
  const conditionCtx = {
316
350
  url,
317
351
  commander,
352
+ readyOn,
318
353
  };
319
354
 
320
355
  // Find matching trigger
@@ -323,6 +358,16 @@ export function createPageTriggerManager(options = {}) {
323
358
  log.debug(() => `📋 No trigger registered for: ${url}`);
324
359
  return Promise.resolve();
325
360
  }
361
+ if (isActionRunning) {
362
+ if (matchingTrigger.concurrency === 'skip') {
363
+ return Promise.resolve();
364
+ }
365
+ return stopCurrentAction().then(() => {
366
+ if (!isActionRunning) {
367
+ return startAction(url, commander, readyOn);
368
+ }
369
+ });
370
+ }
326
371
 
327
372
  log.debug(() => `🚀 Starting action "${matchingTrigger.name}" for: ${url}`);
328
373
 
@@ -436,18 +481,19 @@ export function createPageTriggerManager(options = {}) {
436
481
  */
437
482
  const cleanupCallbacks = [];
438
483
  function onCleanup(callback) {
439
- cleanupCallbacks.push(callback);
440
- abortSignal.addEventListener(
441
- 'abort',
442
- async () => {
443
- try {
444
- await callback();
445
- } catch (e) {
446
- log.debug(() => `⚠️ Cleanup error: ${e.message}`);
447
- }
448
- },
449
- { once: true }
450
- );
484
+ const unsubscribe = subscribeCallbacks(cleanupCallbacks, callback);
485
+ const onAbort = async () => {
486
+ try {
487
+ await callback();
488
+ } catch (e) {
489
+ log.debug(() => `⚠️ Cleanup error: ${e.message}`);
490
+ }
491
+ };
492
+ abortSignal.addEventListener('abort', onAbort, { once: true });
493
+ return () => {
494
+ abortSignal.removeEventListener('abort', onAbort);
495
+ unsubscribe();
496
+ };
451
497
  }
452
498
 
453
499
  // Wrap all commander methods to be abort-aware
@@ -507,36 +553,32 @@ export function createPageTriggerManager(options = {}) {
507
553
  /**
508
554
  * Handle page ready - start matching action
509
555
  */
510
- async function onPageReady({ url }, commander) {
511
- await startAction(url, commander);
512
- }
513
-
514
556
  /**
515
557
  * Check if an action is currently running
516
558
  */
517
- function isRunning() {
518
- return isActionRunning;
519
- }
559
+ const isRunning = () => isActionRunning;
520
560
 
521
561
  /**
522
562
  * Get current trigger name
523
563
  */
524
- function getCurrentTriggerName() {
525
- return currentTrigger?.name || null;
526
- }
564
+ const getCurrentTriggerName = () => currentTrigger?.name || null;
527
565
 
528
566
  /**
529
567
  * Initialize - connect to navigation manager
530
568
  * @param {Object} commander - BrowserCommander instance
531
569
  */
532
570
  function initialize(commander) {
533
- // Stop action before navigation starts
534
- navigationManager.on('onBeforeNavigate', onNavigationStart);
535
-
536
- // Start action when page is ready
537
- navigationManager.on('onPageReady', (event) =>
538
- onPageReady(event, commander)
539
- );
571
+ subscribeReadiness({
572
+ commander,
573
+ navigationManager,
574
+ subscriptions,
575
+ onNavigationStart,
576
+ enqueue: (url, readyOn) => {
577
+ startQueue = startQueue.then(() =>
578
+ startAction(url, commander, readyOn)
579
+ );
580
+ },
581
+ });
540
582
 
541
583
  log.debug(() => '📋 PageTriggerManager initialized');
542
584
  }
@@ -544,12 +586,13 @@ export function createPageTriggerManager(options = {}) {
544
586
  /**
545
587
  * Cleanup
546
588
  */
547
- async function destroy() {
548
- await stopCurrentAction();
549
- triggers.length = 0;
550
- navigationManager.off('onBeforeNavigate', onNavigationStart);
551
- log.debug(() => '📋 PageTriggerManager destroyed');
552
- }
589
+ const destroy = createTriggerDestroy({
590
+ subscriptions,
591
+ getQueue: () => startQueue,
592
+ stopCurrentAction,
593
+ triggers,
594
+ log,
595
+ });
553
596
 
554
597
  return {
555
598
  pageTrigger,
@@ -564,3 +607,48 @@ export function createPageTriggerManager(options = {}) {
564
607
  isActionStoppedError,
565
608
  };
566
609
  }
610
+
611
+ function subscribeReadiness({
612
+ commander,
613
+ navigationManager,
614
+ subscriptions,
615
+ onNavigationStart,
616
+ enqueue,
617
+ }) {
618
+ function subscribe(source, event, listener) {
619
+ source?.on?.(event, listener);
620
+ subscriptions.push(() => source?.off?.(event, listener));
621
+ }
622
+ subscribe(navigationManager, 'onBeforeNavigate', onNavigationStart);
623
+ subscribe(navigationManager, 'onPageReady', (event) =>
624
+ enqueue(event.url, 'networkidle')
625
+ );
626
+ // onNavigationStart happens after old actions have been stopped. Using
627
+ // onUrlChange here would start an action that cleanup immediately aborts.
628
+ subscribe(navigationManager, 'onNavigationStart', (event) =>
629
+ enqueue(event.url, 'urlchange')
630
+ );
631
+ for (const readyOn of ['domcontentloaded', 'load']) {
632
+ subscribe(commander.page, readyOn, () =>
633
+ enqueue(commander.page.url(), readyOn)
634
+ );
635
+ }
636
+ }
637
+
638
+ function createTriggerDestroy({
639
+ subscriptions,
640
+ getQueue,
641
+ stopCurrentAction,
642
+ triggers,
643
+ log,
644
+ }) {
645
+ return async () => {
646
+ for (const unsubscribe of subscriptions.splice(0)) {
647
+ unsubscribe();
648
+ }
649
+ await getQueue();
650
+ await stopCurrentAction();
651
+ triggers.length = 0;
652
+ log.debug(() => '📋 PageTriggerManager destroyed');
653
+ };
654
+ }
@@ -14,6 +14,7 @@ export const READINESS_STATUS = Object.freeze({
14
14
  READY: 'ready',
15
15
  TIMED_OUT: 'timed_out',
16
16
  FAILED: 'failed',
17
+ INTERRUPTED: 'interrupted',
17
18
  });
18
19
 
19
20
  /**
@@ -57,7 +58,7 @@ export function isLongLivedRequest(
57
58
  * @returns {{startedAt: number, timeoutMs: number, elapsedMs: Function, remainingMs: Function, expired: Function}} Deadline handle
58
59
  */
59
60
  export function createDeadline(options = {}) {
60
- const { timeout, now = () => performance.now() } = options;
61
+ const { timeout, now = () => performance.now(), signal } = options;
61
62
 
62
63
  if (!Number.isFinite(timeout) || timeout < 0) {
63
64
  throw new TypeError('createDeadline requires a non-negative timeout');
@@ -72,6 +73,7 @@ export function createDeadline(options = {}) {
72
73
  const remainingMs = () => Math.max(0, Math.round(timeout - elapsed()));
73
74
 
74
75
  return {
76
+ signal,
75
77
  startedAt,
76
78
  timeoutMs: timeout,
77
79
  elapsedMs: () => Math.round(elapsed()),
@@ -92,7 +94,22 @@ export async function sleepWithinDeadline(ms, deadline) {
92
94
  if (capped <= 0) {
93
95
  return;
94
96
  }
95
- await new Promise((resolve) => setTimeout(resolve, capped));
97
+ await runWithinDeadline(
98
+ deadline,
99
+ () =>
100
+ new Promise((resolve) => {
101
+ const timer = setTimeout(done, capped);
102
+ function done() {
103
+ clearTimeout(timer);
104
+ deadline.signal?.removeEventListener('abort', done);
105
+ resolve();
106
+ }
107
+ deadline.signal?.addEventListener('abort', done, { once: true });
108
+ if (deadline.signal?.aborted) {
109
+ done();
110
+ }
111
+ })
112
+ );
96
113
  }
97
114
 
98
115
  /** Marker resolved by the expiry timer in {@link runWithinDeadline}. */
@@ -110,7 +127,14 @@ const DEADLINE_REACHED = Symbol('deadline-reached');
110
127
  * @param {Function} run - Zero-argument function returning a promise
111
128
  * @returns {Promise<{timedOut: boolean, value: *}>} Outcome, or expiry
112
129
  */
113
- export async function runWithinDeadline(deadline, run) {
130
+ export async function runWithinDeadline(
131
+ deadline,
132
+ run,
133
+ signal = deadline?.signal
134
+ ) {
135
+ if (signal?.aborted) {
136
+ return { timedOut: false, interrupted: true };
137
+ }
114
138
  if (!deadline) {
115
139
  return { timedOut: false, value: await run() };
116
140
  }
@@ -127,18 +151,31 @@ export async function runWithinDeadline(deadline, run) {
127
151
  Promise.resolve(operation).catch(() => {});
128
152
 
129
153
  let timer;
154
+ let onAbort;
130
155
  const expiry = new Promise((resolve) => {
131
156
  timer = setTimeout(() => resolve(DEADLINE_REACHED), remaining);
132
157
  });
158
+ const interrupted = Symbol('interrupted');
159
+ const cancellation = new Promise((resolve) => {
160
+ onAbort = () => resolve(interrupted);
161
+ signal?.addEventListener('abort', onAbort, { once: true });
162
+ if (signal?.aborted) {
163
+ onAbort();
164
+ }
165
+ });
133
166
 
134
167
  try {
135
- const outcome = await Promise.race([operation, expiry]);
168
+ const outcome = await Promise.race([operation, expiry, cancellation]);
169
+ if (outcome === interrupted) {
170
+ return { timedOut: false, interrupted: true };
171
+ }
136
172
  if (outcome === DEADLINE_REACHED) {
137
173
  return { timedOut: true, value: undefined };
138
174
  }
139
175
  return { timedOut: false, value: outcome };
140
176
  } finally {
141
177
  clearTimeout(timer);
178
+ signal?.removeEventListener('abort', onAbort);
142
179
  }
143
180
  }
144
181
 
@@ -196,7 +233,7 @@ export function stableCheck(options = {}) {
196
233
  let streak = 0;
197
234
  let lastDetail = {};
198
235
 
199
- while (!deadline.expired()) {
236
+ while (!deadline.expired() && !deadline.signal?.aborted) {
200
237
  const { stable, detail } = normalizeSampleResult(await sample(context));
201
238
  lastDetail = detail;
202
239
 
@@ -285,6 +322,7 @@ export function networkIdleFor(options = {}) {
285
322
 
286
323
  const idle = await networkTracker.waitForNetworkIdle({
287
324
  timeout,
325
+ signal: deadline.signal,
288
326
  ...(idleForMs === undefined ? {} : { idleTime: idleForMs }),
289
327
  });
290
328
 
@@ -426,9 +464,14 @@ export async function runReadinessChecks(options = {}) {
426
464
  const skipped = [];
427
465
  const pending = [];
428
466
  const evidence = [];
467
+ let deadlineReached = false;
429
468
 
430
469
  for (const [index, check] of checks.entries()) {
431
- if (deadline.expired() && failed.length > 0) {
470
+ if (
471
+ deadlineReached ||
472
+ deadline.signal?.aborted ||
473
+ (deadline.expired() && failed.length > 0)
474
+ ) {
432
475
  pending.push(...checks.slice(index).map((entry) => entry.name));
433
476
  break;
434
477
  }
@@ -436,7 +479,23 @@ export async function runReadinessChecks(options = {}) {
436
479
  const startedAtMs = deadline.elapsedMs();
437
480
  let outcome;
438
481
  try {
439
- outcome = await check.run({ ...context, deadline });
482
+ const bounded = await runWithinDeadline(deadline, () =>
483
+ check.run({ ...context, deadline })
484
+ );
485
+ // Timer callbacks and the rounded monotonic clock can disagree briefly.
486
+ // Once expiry ends a wait, another clock sample must not undo it.
487
+ deadlineReached ||= bounded.timedOut;
488
+ outcome =
489
+ bounded.timedOut || bounded.interrupted
490
+ ? {
491
+ satisfied: false,
492
+ detail: {
493
+ reason: bounded.interrupted
494
+ ? 'interrupted'
495
+ : 'deadline reached',
496
+ },
497
+ }
498
+ : bounded.value;
440
499
  } catch (error) {
441
500
  outcome = { satisfied: false, detail: { error: error.message } };
442
501
  }
@@ -460,15 +519,18 @@ export async function runReadinessChecks(options = {}) {
460
519
  }
461
520
  }
462
521
 
463
- const ready = failed.length === 0 && pending.length === 0;
464
- const timedOut = !ready && deadline.expired();
522
+ const interrupted = Boolean(deadline.signal?.aborted);
523
+ const ready = !interrupted && failed.length === 0 && pending.length === 0;
524
+ const timedOut = !ready && (deadlineReached || deadline.expired());
465
525
 
466
526
  return {
467
- status: ready
468
- ? READINESS_STATUS.READY
469
- : timedOut
470
- ? READINESS_STATUS.TIMED_OUT
471
- : READINESS_STATUS.FAILED,
527
+ status: interrupted
528
+ ? READINESS_STATUS.INTERRUPTED
529
+ : ready
530
+ ? READINESS_STATUS.READY
531
+ : timedOut
532
+ ? READINESS_STATUS.TIMED_OUT
533
+ : READINESS_STATUS.FAILED,
472
534
  ready,
473
535
  checks: { satisfied, failed, skipped, pending },
474
536
  evidence,
@@ -0,0 +1,20 @@
1
+ /** Register a callback and return a remover that can safely be called again. */
2
+ export function subscribeCallbacks(callbacks, callback) {
3
+ callbacks?.push(callback);
4
+ let removed = false;
5
+ return () => {
6
+ if (removed) {
7
+ return;
8
+ }
9
+ removed = true;
10
+ removeCallback(callbacks, callback);
11
+ };
12
+ }
13
+
14
+ /** Remove one matching callback, retaining other registrations. */
15
+ export function removeCallback(callbacks, callback) {
16
+ const index = callbacks?.indexOf(callback) ?? -1;
17
+ if (index !== -1) {
18
+ callbacks.splice(index, 1);
19
+ }
20
+ }
@@ -695,18 +695,56 @@ export class WebDriverPage extends EventEmitter {
695
695
  // ==========================================================================
696
696
 
697
697
  async cookies() {
698
+ if (await this.bidi()) {
699
+ const state = await this.bidiCommand('storage.getCookies');
700
+ return state.cookies.map((cookie) => ({
701
+ ...cookie,
702
+ value: cookie.value.value,
703
+ expires: cookie.expiry ?? -1,
704
+ sameSite: cookie.sameSite?.replace(/^./u, (letter) =>
705
+ letter.toUpperCase()
706
+ ),
707
+ }));
708
+ }
698
709
  const cookies = await this.driver.manage().getCookies();
699
710
  return cookies.map(fromWebDriverCookie);
700
711
  }
701
712
 
702
713
  async setCookie(...cookies) {
703
714
  for (const cookie of cookies) {
715
+ if (await this.bidi()) {
716
+ const { expires, url, ...value } = cookie;
717
+ await this.bidiCommand('storage.setCookie', {
718
+ cookie: {
719
+ ...value,
720
+ domain:
721
+ cookie.domain ?? new URL(url ?? (await this.syncUrl())).hostname,
722
+ ...(url
723
+ ? { secure: cookie.secure ?? new URL(url).protocol === 'https:' }
724
+ : {}),
725
+ value: { type: 'string', value: cookie.value },
726
+ sameSite: cookie.sameSite?.toLowerCase() ?? 'lax',
727
+ ...(expires > 0 ? { expiry: expires } : {}),
728
+ },
729
+ });
730
+ continue;
731
+ }
704
732
  await this.driver.manage().addCookie(toWebDriverCookie(cookie));
705
733
  }
706
734
  }
707
735
 
708
736
  async deleteCookie(...cookies) {
709
737
  for (const cookie of cookies) {
738
+ if (await this.bidi()) {
739
+ await this.bidiCommand('storage.deleteCookies', {
740
+ filter: {
741
+ name: cookie.name,
742
+ domain: cookie.domain,
743
+ path: cookie.path,
744
+ },
745
+ });
746
+ continue;
747
+ }
710
748
  await this.driver.manage().deleteCookie(cookie.name);
711
749
  }
712
750
  }
@@ -1,5 +1,8 @@
1
1
  import { TIMING } from '../core/constants.js';
2
- import { isNavigationError } from '../core/navigation-safety.js';
2
+ import {
3
+ waitNavigationState,
4
+ recoverWaitError,
5
+ } from '../core/navigation-safety.js';
3
6
 
4
7
  /**
5
8
  * Helper to create Playwright locator from selector string
@@ -9,12 +12,16 @@ import { isNavigationError } from '../core/navigation-safety.js';
9
12
  * @param {string} options.selector - CSS selector
10
13
  * @returns {Object} - Playwright locator
11
14
  */
12
- export function createPlaywrightLocator(options = {}) {
13
- const { page, selector } = options;
14
-
15
+ export function requireSelector(selector) {
15
16
  if (!selector) {
16
17
  throw new Error('selector is required in options');
17
18
  }
19
+ }
20
+
21
+ export function createPlaywrightLocator(options = {}) {
22
+ const { page, selector } = options;
23
+
24
+ requireSelector(selector);
18
25
  // Check if selector has :nth-of-type(n) pattern
19
26
  const nthOfTypeMatch = selector.match(/^(.+):nth-of-type\((\d+)\)$/);
20
27
 
@@ -37,20 +44,24 @@ export function createPlaywrightLocator(options = {}) {
37
44
  * @returns {Promise<Object|null>} - Locator for Playwright, Element for Puppeteer (can be null)
38
45
  */
39
46
  export async function getLocatorOrElement(options = {}) {
40
- const { page, engine, selector } = options;
41
-
42
- if (!selector) {
43
- throw new Error('selector is required in options');
47
+ const { page, engine, selector, index } = options;
48
+ if (index !== undefined && (!Number.isInteger(index) || index < 0)) {
49
+ throw new TypeError('index must be a non-negative integer');
44
50
  }
51
+
52
+ requireSelector(selector);
45
53
  if (typeof selector !== 'string') {
46
54
  return selector; // Already a locator/element
47
55
  }
48
56
 
49
57
  if (engine === 'playwright') {
50
- return createPlaywrightLocator({ page, selector });
58
+ const locator = createPlaywrightLocator({ page, selector });
59
+ return index === undefined ? locator : locator.nth(index);
51
60
  } else {
52
61
  // For Puppeteer, return element (can be null if doesn't exist)
53
- return await page.$(selector);
62
+ return index === undefined
63
+ ? await page.$(selector)
64
+ : ((await page.$$(selector))[index] ?? null);
54
65
  }
55
66
  }
56
67
 
@@ -67,45 +78,60 @@ export async function getLocatorOrElement(options = {}) {
67
78
  * @throws {Error} - If element not found or not visible within timeout (unless navigation error and throwOnNavigation is false)
68
79
  */
69
80
  export async function waitForLocatorOrElement(options = {}) {
70
- const {
71
- page,
72
- engine,
73
- selector,
74
- timeout = TIMING.DEFAULT_TIMEOUT,
75
- throwOnNavigation = true,
76
- } = options;
77
-
78
- if (!selector) {
79
- throw new Error('selector is required in options');
80
- }
81
+ requireSelector(options.selector);
82
+ const { page, engine, selector } = options;
83
+ const timeout =
84
+ options.timeout === undefined ? TIMING.DEFAULT_TIMEOUT : options.timeout;
81
85
 
86
+ const initial = waitNavigationState(page, options.navigationManager);
82
87
  try {
83
88
  if (engine === 'playwright') {
84
- const locator = await getLocatorOrElement({ page, engine, selector });
89
+ const locator = await getLocatorOrElement(options);
85
90
  // Use .first() to handle multiple matches (Playwright strict mode)
86
91
  const firstLocator = locator.first();
87
92
  await firstLocator.waitFor({ state: 'visible', timeout });
88
93
  return firstLocator;
89
94
  } else {
95
+ if (typeof selector !== 'string') {
96
+ await waitForVisible({ engine, locatorOrElement: selector, timeout });
97
+ return selector;
98
+ }
99
+ if (options.index !== undefined && typeof selector === 'string') {
100
+ if (!Number.isInteger(options.index) || options.index < 0) {
101
+ throw new TypeError('index must be a non-negative integer');
102
+ }
103
+ await page.waitForFunction(
104
+ (sel, index) => {
105
+ const element = document.querySelectorAll(sel)[index];
106
+ return (
107
+ element &&
108
+ element.getBoundingClientRect().width > 0 &&
109
+ element.getBoundingClientRect().height > 0 &&
110
+ window.getComputedStyle(element).visibility !== 'hidden'
111
+ );
112
+ },
113
+ { timeout },
114
+ selector,
115
+ options.index
116
+ );
117
+ return getLocatorOrElement(options);
118
+ }
90
119
  // Puppeteer: wait for selector to be visible (returns first match by default)
91
120
  await page.waitForSelector(selector, { visible: true, timeout });
92
- const element = await page.$(selector);
121
+ const element = await getLocatorOrElement(options);
93
122
  if (!element) {
94
123
  throw new Error(`Element not found after waiting: ${selector}`);
95
124
  }
96
125
  return element;
97
126
  }
98
- } catch (error) {
99
- if (isNavigationError(error)) {
100
- console.log(
101
- '⚠️ Navigation detected during waitForLocatorOrElement, recovering gracefully'
102
- );
103
- if (throwOnNavigation) {
104
- throw error;
105
- }
106
- return null;
107
- }
108
- throw error;
127
+ } catch (caught) {
128
+ return recoverWaitError(
129
+ caught,
130
+ options,
131
+ initial,
132
+ 'waitForLocatorOrElement',
133
+ null
134
+ );
109
135
  }
110
136
  }
111
137
 
@@ -149,9 +175,7 @@ export async function waitForVisible(options = {}) {
149
175
  export function locator(options = {}) {
150
176
  const { page, engine, selector } = options;
151
177
 
152
- if (!selector) {
153
- throw new Error('selector is required in options');
154
- }
178
+ requireSelector(selector);
155
179
 
156
180
  if (engine === 'playwright') {
157
181
  return createPlaywrightLocator({ page, selector });