browser-commander 0.27.0 → 0.29.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 (93) hide show
  1. package/.jscpd-baseline.json +5 -19
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +23 -9
  4. package/eslint.config.js +11 -0
  5. package/package.json +5 -2
  6. package/src/browser/browser-profile-files.js +5 -2
  7. package/src/browser/connector.js +215 -36
  8. package/src/browser/launch-options.js +2 -1
  9. package/src/browser/launch-restrictions.json +33 -2
  10. package/src/browser/launcher.js +26 -1
  11. package/src/browser/persistent-session.js +432 -0
  12. package/src/browser/profile-directory.js +20 -0
  13. package/src/browser/real-browser.js +7 -2
  14. package/src/browser/restrictions.js +13 -4
  15. package/src/browser/session-adoption.js +96 -0
  16. package/src/browser/session-watchdog.js +71 -0
  17. package/src/browser/session-worker.js +82 -0
  18. package/src/browser/webdriver.js +4 -0
  19. package/src/capture/encoding.js +327 -0
  20. package/src/capture/errors.js +12 -0
  21. package/src/capture/files.js +9 -0
  22. package/src/capture/index.js +5 -0
  23. package/src/capture/recording.js +136 -0
  24. package/src/capture/screenshot.js +222 -0
  25. package/src/capture/video.js +186 -0
  26. package/src/capture/viewport.js +68 -0
  27. package/src/capture/worker.js +22 -0
  28. package/src/cli/args.js +69 -1
  29. package/src/cli/commands.js +116 -2
  30. package/src/cli/high-level-methods.js +47 -3
  31. package/src/cli/sessions.js +14 -3
  32. package/src/core/commander-features.js +110 -0
  33. package/src/core/constants.js +1 -0
  34. package/src/core/navigation-manager.js +4 -2
  35. package/src/core/navigation-readiness.js +30 -2
  36. package/src/core/navigation-safety.js +52 -0
  37. package/src/core/page-trigger-manager.js +280 -156
  38. package/src/core/readiness.js +10 -2
  39. package/src/elements/locators.js +26 -32
  40. package/src/elements/selectors.js +37 -81
  41. package/src/elements/visibility.js +32 -10
  42. package/src/factory.js +13 -12
  43. package/src/index.js +4 -0
  44. package/src/interactions/click.js +44 -0
  45. package/src/interactions/overlays.js +73 -0
  46. package/src/traces/bundle.js +55 -8
  47. package/src/traces/checkpoints.js +103 -0
  48. package/src/traces/links.js +157 -2
  49. package/src/traces/mutation-stream.js +9 -2
  50. package/src/traces/network.js +309 -0
  51. package/src/traces/page-capture.js +203 -68
  52. package/src/traces/reader.js +8 -10
  53. package/src/traces/recorder.js +253 -247
  54. package/src/traces/redaction.js +89 -6
  55. package/src/traces/render.js +168 -0
  56. package/src/traces/rolling-bundle.js +240 -0
  57. package/src/traces/rolling.js +77 -0
  58. package/src/traces/start-cleanup.js +15 -0
  59. package/src/traces/stop.js +160 -0
  60. package/src/traces/storage.js +43 -0
  61. package/src/utilities/subprocess.js +32 -0
  62. package/tests/e2e/cli.e2e.test.js +20 -1
  63. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  64. package/tests/e2e/issue-160.e2e.test.js +291 -0
  65. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
  66. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
  67. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
  68. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
  69. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
  70. package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
  71. package/tests/unit/browser/issue-160-connector.test.js +157 -0
  72. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  73. package/tests/unit/browser/launcher.test.js +5 -1
  74. package/tests/unit/browser/persistent-session.test.js +59 -0
  75. package/tests/unit/browser/quiet-ui.test.js +16 -0
  76. package/tests/unit/browser/tab-selection.test.js +38 -0
  77. package/tests/unit/capture/capture.test.js +52 -0
  78. package/tests/unit/capture/cleanup.test.js +24 -0
  79. package/tests/unit/capture/viewport.test.js +168 -0
  80. package/tests/unit/cli/dispatcher.test.js +25 -0
  81. package/tests/unit/core/interrupted-navigation.test.js +32 -0
  82. package/tests/unit/core/readiness.test.js +21 -0
  83. package/tests/unit/core/trigger-readiness.test.js +39 -0
  84. package/tests/unit/core/trigger-stop-race.test.js +80 -0
  85. package/tests/unit/elements/visible-count.test.js +24 -0
  86. package/tests/unit/elements/wait-navigation.test.js +26 -0
  87. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  88. package/tests/unit/interactions/overlays.test.js +58 -0
  89. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  90. package/tests/unit/traces/bundle.test.js +16 -0
  91. package/tests/unit/traces/issue-160-privacy.test.js +110 -0
  92. package/tests/unit/traces/issue-160-rotation.test.js +140 -0
  93. package/tests/unit/traces/network.test.js +149 -0
@@ -170,7 +170,8 @@ export function createPageTriggerManager(options = {}) {
170
170
  }
171
171
 
172
172
  // Registered triggers
173
- const triggers = [];
173
+ const triggers = [],
174
+ subscriptions = [];
174
175
 
175
176
  // Current action state
176
177
  let currentTrigger = null;
@@ -178,8 +179,26 @@ export function createPageTriggerManager(options = {}) {
178
179
  let actionPromise = null;
179
180
  let actionStopPromise = null;
180
181
  let actionStopResolve = null;
181
- let isActionRunning = false;
182
- let isStopping = false;
182
+ let isActionRunning = false,
183
+ isStopping = false;
184
+ let startQueue = Promise.resolve();
185
+ let pendingStart = null;
186
+ let destroyed = false;
187
+
188
+ function drainPending() {
189
+ if (destroyed || isActionRunning || isStopping || !pendingStart) {
190
+ return;
191
+ }
192
+ const pending = pendingStart;
193
+ pendingStart = null;
194
+ if (
195
+ triggers.includes(pending.trigger) &&
196
+ (!pending.commander.page?.url ||
197
+ pending.commander.page.url() === pending.url)
198
+ ) {
199
+ void startAction(pending.url, pending.commander, pending.readyOn);
200
+ }
201
+ }
183
202
 
184
203
  /**
185
204
  * Register a page trigger
@@ -191,7 +210,24 @@ export function createPageTriggerManager(options = {}) {
191
210
  * @returns {Function} - Unregister function
192
211
  */
193
212
  function pageTrigger(config) {
194
- const { condition, action, name = 'unnamed', priority = 0 } = config;
213
+ const {
214
+ condition,
215
+ action,
216
+ name = 'unnamed',
217
+ priority = 0,
218
+ readyOn = 'networkidle',
219
+ concurrency = 'skip',
220
+ } = config;
221
+ if (
222
+ !['urlchange', 'domcontentloaded', 'load', 'networkidle'].includes(
223
+ readyOn
224
+ )
225
+ ) {
226
+ throw new RangeError(`Unknown trigger readyOn: ${readyOn}`);
227
+ }
228
+ if (!['skip', 'restart', 'queue'].includes(concurrency)) {
229
+ throw new RangeError(`Unknown trigger concurrency: ${concurrency}`);
230
+ }
195
231
 
196
232
  if (typeof condition !== 'function') {
197
233
  throw new Error('condition must be a function');
@@ -205,6 +241,8 @@ export function createPageTriggerManager(options = {}) {
205
241
  action,
206
242
  name,
207
243
  priority,
244
+ readyOn,
245
+ concurrency,
208
246
  };
209
247
 
210
248
  triggers.push(triggerConfig);
@@ -221,6 +259,9 @@ export function createPageTriggerManager(options = {}) {
221
259
  const index = triggers.indexOf(triggerConfig);
222
260
  if (index !== -1) {
223
261
  triggers.splice(index, 1);
262
+ if (pendingStart?.trigger === triggerConfig) {
263
+ pendingStart = null;
264
+ }
224
265
  log.debug(() => `📋 Unregistered page trigger: "${name}"`);
225
266
  }
226
267
  };
@@ -234,7 +275,7 @@ export function createPageTriggerManager(options = {}) {
234
275
  function findMatchingTrigger(ctx) {
235
276
  for (const config of triggers) {
236
277
  try {
237
- if (config.condition(ctx)) {
278
+ if (config.readyOn === ctx.readyOn && config.condition(ctx)) {
238
279
  return config;
239
280
  }
240
281
  } catch (e) {
@@ -277,9 +318,10 @@ export function createPageTriggerManager(options = {}) {
277
318
  }
278
319
 
279
320
  // Wait for action to finish (with timeout)
280
- const timeoutMs = 10000; // 10 second max wait
321
+ const timeoutMs = options.stopGraceMs ?? 10000;
322
+ let stopTimer;
281
323
  const timeoutPromise = new Promise((resolve) => {
282
- setTimeout(() => {
324
+ stopTimer = setTimeout(() => {
283
325
  log.debug(
284
326
  () =>
285
327
  `⚠️ Action "${currentTrigger?.name}" did not stop gracefully within ${timeoutMs}ms`
@@ -289,6 +331,16 @@ export function createPageTriggerManager(options = {}) {
289
331
  });
290
332
 
291
333
  await Promise.race([actionPromise, timeoutPromise]);
334
+ clearTimeout(stopTimer);
335
+ // An action can ignore cancellation. Keep it in flight until it settles,
336
+ // so a second form handler can never race it after the grace period.
337
+ if (isActionRunning) {
338
+ isStopping = false;
339
+ actionStopResolve?.();
340
+ actionStopPromise = null;
341
+ actionStopResolve = null;
342
+ return;
343
+ }
292
344
 
293
345
  // Cleanup
294
346
  isActionRunning = false;
@@ -305,6 +357,7 @@ export function createPageTriggerManager(options = {}) {
305
357
  }
306
358
 
307
359
  log.debug(() => '✅ Action stopped');
360
+ drainPending();
308
361
  }
309
362
 
310
363
  /**
@@ -312,11 +365,15 @@ export function createPageTriggerManager(options = {}) {
312
365
  * @param {string} url - URL to start action for
313
366
  * @param {Object} commander - BrowserCommander instance
314
367
  */
315
- function startAction(url, commander) {
368
+ function startAction(url, commander, readyOn = 'networkidle') {
369
+ if (destroyed) {
370
+ return Promise.resolve();
371
+ }
316
372
  // Create context for condition checking
317
373
  const conditionCtx = {
318
374
  url,
319
375
  commander,
376
+ readyOn,
320
377
  };
321
378
 
322
379
  // Find matching trigger
@@ -325,6 +382,20 @@ export function createPageTriggerManager(options = {}) {
325
382
  log.debug(() => `📋 No trigger registered for: ${url}`);
326
383
  return Promise.resolve();
327
384
  }
385
+ if (isActionRunning) {
386
+ if (
387
+ matchingTrigger.concurrency === 'skip' &&
388
+ !isStopping &&
389
+ !currentAbortController?.signal.aborted
390
+ ) {
391
+ return Promise.resolve();
392
+ }
393
+ // One latest start bounds memory while preserving readiness during stop.
394
+ pendingStart = { url, commander, readyOn, trigger: matchingTrigger };
395
+ return matchingTrigger.concurrency === 'restart'
396
+ ? stopCurrentAction()
397
+ : Promise.resolve();
398
+ }
328
399
 
329
400
  log.debug(() => `🚀 Starting action "${matchingTrigger.name}" for: ${url}`);
330
401
 
@@ -334,12 +405,15 @@ export function createPageTriggerManager(options = {}) {
334
405
  isActionRunning = true;
335
406
 
336
407
  // Create action context
337
- const context = createActionContext({
338
- url,
339
- abortSignal: currentAbortController.signal,
340
- commander,
341
- triggerName: matchingTrigger.name,
342
- });
408
+ const context = createActionContext(
409
+ {
410
+ url,
411
+ abortSignal: currentAbortController.signal,
412
+ commander,
413
+ triggerName: matchingTrigger.name,
414
+ },
415
+ log
416
+ );
343
417
 
344
418
  // Run action
345
419
  actionPromise = (async () => {
@@ -368,6 +442,7 @@ export function createPageTriggerManager(options = {}) {
368
442
  isActionRunning = false;
369
443
  currentTrigger = null;
370
444
  currentAbortController = null;
445
+ globalThis.queueMicrotask(drainPending);
371
446
  }
372
447
  }
373
448
  })();
@@ -380,166 +455,44 @@ export function createPageTriggerManager(options = {}) {
380
455
  * @param {Object} options
381
456
  * @returns {Object} - Action context
382
457
  */
383
- function createActionContext(options) {
384
- const { url, abortSignal, commander, triggerName } = options;
385
-
386
- /**
387
- * Check if stopped and throw if so
388
- */
389
- function checkStopped() {
390
- if (abortSignal.aborted) {
391
- throw new ActionStoppedError(`Action "${triggerName}" stopped`);
392
- }
393
- }
394
-
395
- /**
396
- * Wrap async function to check abort before and after
397
- */
398
- function wrapAsync(fn) {
399
- return async (...args) => {
400
- checkStopped();
401
- const result = await fn(...args);
402
- checkStopped();
403
- return result;
404
- };
405
- }
406
-
407
- /**
408
- * Create abort-aware loop helper
409
- * Use this instead of for/while loops for stoppability
410
- */
411
- async function forEach(items, callback) {
412
- for (let i = 0; i < items.length; i++) {
413
- checkStopped();
414
- await callback(items[i], i, items);
415
- }
416
- }
417
-
418
- /**
419
- * Wait with abort support
420
- */
421
- async function wait(ms) {
422
- checkStopped();
423
- await new Promise((resolve, reject) => {
424
- const timeout = setTimeout(resolve, ms);
425
- abortSignal.addEventListener(
426
- 'abort',
427
- () => {
428
- clearTimeout(timeout);
429
- reject(new ActionStoppedError());
430
- },
431
- { once: true }
432
- );
433
- });
434
- }
435
-
436
- /**
437
- * Register cleanup callback (called when action stops)
438
- */
439
- const cleanupCallbacks = [];
440
- function onCleanup(callback) {
441
- const unsubscribe = subscribeCallbacks(cleanupCallbacks, callback);
442
- const onAbort = async () => {
443
- try {
444
- await callback();
445
- } catch (e) {
446
- log.debug(() => `⚠️ Cleanup error: ${e.message}`);
447
- }
448
- };
449
- abortSignal.addEventListener('abort', onAbort, { once: true });
450
- return () => {
451
- abortSignal.removeEventListener('abort', onAbort);
452
- unsubscribe();
453
- };
454
- }
455
-
456
- // Wrap all commander methods to be abort-aware
457
- const wrappedCommander = {};
458
- for (const [key, value] of Object.entries(commander)) {
459
- if (
460
- typeof value === 'function' &&
461
- key !== 'destroy' &&
462
- key !== 'pageTrigger'
463
- ) {
464
- wrappedCommander[key] = wrapAsync(value);
465
- } else {
466
- wrappedCommander[key] = value;
467
- }
468
- }
469
-
470
- return {
471
- // URL this action is running for
472
- url,
473
-
474
- // Abort signal - use with fetch() or custom abort logic
475
- abortSignal,
476
-
477
- // Check if action should stop
478
- isStopped: () => abortSignal.aborted,
479
-
480
- // Throw if stopped - call this in loops
481
- checkStopped,
482
-
483
- // Abort-aware iteration helper
484
- forEach,
485
-
486
- // Abort-aware wait
487
- wait,
488
-
489
- // Register cleanup callback
490
- onCleanup,
491
-
492
- // Wrapped commander - all methods throw ActionStoppedError if stopped
493
- commander: wrappedCommander,
494
-
495
- // Original commander (use carefully)
496
- rawCommander: commander,
497
-
498
- // Trigger name for debugging
499
- triggerName,
500
- };
501
- }
502
458
 
503
459
  /**
504
460
  * Handle navigation start - stop current action first
505
461
  */
506
462
  async function onNavigationStart() {
463
+ pendingStart = null;
507
464
  await stopCurrentAction();
508
465
  }
509
466
 
510
467
  /**
511
468
  * Handle page ready - start matching action
512
469
  */
513
- async function onPageReady({ url }, commander) {
514
- await startAction(url, commander);
515
- }
516
-
517
470
  /**
518
471
  * Check if an action is currently running
519
472
  */
520
- function isRunning() {
521
- return isActionRunning;
522
- }
473
+ const isRunning = () => isActionRunning;
523
474
 
524
475
  /**
525
476
  * Get current trigger name
526
477
  */
527
- function getCurrentTriggerName() {
528
- return currentTrigger?.name || null;
529
- }
478
+ const getCurrentTriggerName = () => currentTrigger?.name || null;
530
479
 
531
480
  /**
532
481
  * Initialize - connect to navigation manager
533
482
  * @param {Object} commander - BrowserCommander instance
534
483
  */
535
484
  function initialize(commander) {
536
- // Stop action before navigation starts
537
- navigationManager.on('onBeforeNavigate', onNavigationStart);
538
-
539
- // Start action when page is ready
540
- navigationManager.on('onPageReady', (event) =>
541
- onPageReady(event, commander)
542
- );
485
+ subscribeReadiness({
486
+ commander,
487
+ navigationManager,
488
+ subscriptions,
489
+ onNavigationStart,
490
+ enqueue: (url, readyOn) => {
491
+ startQueue = startQueue.then(() =>
492
+ startAction(url, commander, readyOn)
493
+ );
494
+ },
495
+ });
543
496
 
544
497
  log.debug(() => '📋 PageTriggerManager initialized');
545
498
  }
@@ -547,12 +500,18 @@ export function createPageTriggerManager(options = {}) {
547
500
  /**
548
501
  * Cleanup
549
502
  */
550
- async function destroy() {
551
- await stopCurrentAction();
552
- triggers.length = 0;
553
- navigationManager.off('onBeforeNavigate', onNavigationStart);
554
- log.debug(() => '📋 PageTriggerManager destroyed');
555
- }
503
+ const cleanup = createTriggerDestroy({
504
+ subscriptions,
505
+ getQueue: () => startQueue,
506
+ stopCurrentAction,
507
+ triggers,
508
+ log,
509
+ });
510
+ const destroy = async () => {
511
+ destroyed = true;
512
+ pendingStart = null;
513
+ await cleanup();
514
+ };
556
515
 
557
516
  return {
558
517
  pageTrigger,
@@ -567,3 +526,168 @@ export function createPageTriggerManager(options = {}) {
567
526
  isActionStoppedError,
568
527
  };
569
528
  }
529
+
530
+ function subscribeReadiness({
531
+ commander,
532
+ navigationManager,
533
+ subscriptions,
534
+ onNavigationStart,
535
+ enqueue,
536
+ }) {
537
+ function subscribe(source, event, listener) {
538
+ source?.on?.(event, listener);
539
+ subscriptions.push(() => source?.off?.(event, listener));
540
+ }
541
+ subscribe(navigationManager, 'onBeforeNavigate', onNavigationStart);
542
+ subscribe(navigationManager, 'onPageReady', (event) =>
543
+ enqueue(event.url, 'networkidle')
544
+ );
545
+ // onNavigationStart happens after old actions have been stopped. Using
546
+ // onUrlChange here would start an action that cleanup immediately aborts.
547
+ subscribe(navigationManager, 'onNavigationStart', (event) =>
548
+ enqueue(event.url, 'urlchange')
549
+ );
550
+ for (const readyOn of ['domcontentloaded', 'load']) {
551
+ subscribe(commander.page, readyOn, () =>
552
+ enqueue(commander.page.url(), readyOn)
553
+ );
554
+ }
555
+ }
556
+
557
+ function createTriggerDestroy({
558
+ subscriptions,
559
+ getQueue,
560
+ stopCurrentAction,
561
+ triggers,
562
+ log,
563
+ }) {
564
+ return async () => {
565
+ for (const unsubscribe of subscriptions.splice(0)) {
566
+ unsubscribe();
567
+ }
568
+ await getQueue();
569
+ await stopCurrentAction();
570
+ triggers.length = 0;
571
+ log.debug(() => '📋 PageTriggerManager destroyed');
572
+ };
573
+ }
574
+
575
+ function createActionContext(options, log) {
576
+ const { url, abortSignal, commander, triggerName } = options;
577
+
578
+ /**
579
+ * Check if stopped and throw if so
580
+ */
581
+ function checkStopped() {
582
+ if (abortSignal.aborted) {
583
+ throw new ActionStoppedError(`Action "${triggerName}" stopped`);
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Wrap async function to check abort before and after
589
+ */
590
+ function wrapAsync(fn) {
591
+ return async (...args) => {
592
+ checkStopped();
593
+ const result = await fn(...args);
594
+ checkStopped();
595
+ return result;
596
+ };
597
+ }
598
+
599
+ /**
600
+ * Create abort-aware loop helper
601
+ * Use this instead of for/while loops for stoppability
602
+ */
603
+ async function forEach(items, callback) {
604
+ for (let i = 0; i < items.length; i++) {
605
+ checkStopped();
606
+ await callback(items[i], i, items);
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Wait with abort support
612
+ */
613
+ async function wait(ms) {
614
+ checkStopped();
615
+ await new Promise((resolve, reject) => {
616
+ const timeout = setTimeout(resolve, ms);
617
+ abortSignal.addEventListener(
618
+ 'abort',
619
+ () => {
620
+ clearTimeout(timeout);
621
+ reject(new ActionStoppedError());
622
+ },
623
+ { once: true }
624
+ );
625
+ });
626
+ }
627
+
628
+ /**
629
+ * Register cleanup callback (called when action stops)
630
+ */
631
+ const cleanupCallbacks = [];
632
+ function onCleanup(callback) {
633
+ const unsubscribe = subscribeCallbacks(cleanupCallbacks, callback);
634
+ const onAbort = async () => {
635
+ try {
636
+ await callback();
637
+ } catch (e) {
638
+ log.debug(() => `⚠️ Cleanup error: ${e.message}`);
639
+ }
640
+ };
641
+ abortSignal.addEventListener('abort', onAbort, { once: true });
642
+ return () => {
643
+ abortSignal.removeEventListener('abort', onAbort);
644
+ unsubscribe();
645
+ };
646
+ }
647
+
648
+ // Wrap all commander methods to be abort-aware
649
+ const wrappedCommander = {};
650
+ for (const [key, value] of Object.entries(commander)) {
651
+ if (
652
+ typeof value === 'function' &&
653
+ key !== 'destroy' &&
654
+ key !== 'pageTrigger'
655
+ ) {
656
+ wrappedCommander[key] = wrapAsync(value);
657
+ } else {
658
+ wrappedCommander[key] = value;
659
+ }
660
+ }
661
+
662
+ return {
663
+ // URL this action is running for
664
+ url,
665
+
666
+ // Abort signal - use with fetch() or custom abort logic
667
+ abortSignal,
668
+
669
+ // Check if action should stop
670
+ isStopped: () => abortSignal.aborted,
671
+
672
+ // Throw if stopped - call this in loops
673
+ checkStopped,
674
+
675
+ // Abort-aware iteration helper
676
+ forEach,
677
+
678
+ // Abort-aware wait
679
+ wait,
680
+
681
+ // Register cleanup callback
682
+ onCleanup,
683
+
684
+ // Wrapped commander - all methods throw ActionStoppedError if stopped
685
+ commander: wrappedCommander,
686
+
687
+ // Original commander (use carefully)
688
+ rawCommander: commander,
689
+
690
+ // Trigger name for debugging
691
+ triggerName,
692
+ };
693
+ }
@@ -464,9 +464,14 @@ export async function runReadinessChecks(options = {}) {
464
464
  const skipped = [];
465
465
  const pending = [];
466
466
  const evidence = [];
467
+ let deadlineReached = false;
467
468
 
468
469
  for (const [index, check] of checks.entries()) {
469
- if (deadline.signal?.aborted || (deadline.expired() && failed.length > 0)) {
470
+ if (
471
+ deadlineReached ||
472
+ deadline.signal?.aborted ||
473
+ (deadline.expired() && failed.length > 0)
474
+ ) {
470
475
  pending.push(...checks.slice(index).map((entry) => entry.name));
471
476
  break;
472
477
  }
@@ -477,6 +482,9 @@ export async function runReadinessChecks(options = {}) {
477
482
  const bounded = await runWithinDeadline(deadline, () =>
478
483
  check.run({ ...context, deadline })
479
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;
480
488
  outcome =
481
489
  bounded.timedOut || bounded.interrupted
482
490
  ? {
@@ -513,7 +521,7 @@ export async function runReadinessChecks(options = {}) {
513
521
 
514
522
  const interrupted = Boolean(deadline.signal?.aborted);
515
523
  const ready = !interrupted && failed.length === 0 && pending.length === 0;
516
- const timedOut = !ready && deadline.expired();
524
+ const timedOut = !ready && (deadlineReached || deadline.expired());
517
525
 
518
526
  return {
519
527
  status: interrupted
@@ -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
 
@@ -42,9 +49,7 @@ export async function getLocatorOrElement(options = {}) {
42
49
  throw new TypeError('index must be a non-negative integer');
43
50
  }
44
51
 
45
- if (!selector) {
46
- throw new Error('selector is required in options');
47
- }
52
+ requireSelector(selector);
48
53
  if (typeof selector !== 'string') {
49
54
  return selector; // Already a locator/element
50
55
  }
@@ -73,18 +78,12 @@ export async function getLocatorOrElement(options = {}) {
73
78
  * @throws {Error} - If element not found or not visible within timeout (unless navigation error and throwOnNavigation is false)
74
79
  */
75
80
  export async function waitForLocatorOrElement(options = {}) {
76
- const {
77
- page,
78
- engine,
79
- selector,
80
- timeout = TIMING.DEFAULT_TIMEOUT,
81
- throwOnNavigation = true,
82
- } = options;
83
-
84
- if (!selector) {
85
- throw new Error('selector is required in options');
86
- }
81
+ requireSelector(options.selector);
82
+ const { page, engine, selector } = options;
83
+ const timeout =
84
+ options.timeout === undefined ? TIMING.DEFAULT_TIMEOUT : options.timeout;
87
85
 
86
+ const initial = waitNavigationState(page, options.navigationManager);
88
87
  try {
89
88
  if (engine === 'playwright') {
90
89
  const locator = await getLocatorOrElement(options);
@@ -125,17 +124,14 @@ export async function waitForLocatorOrElement(options = {}) {
125
124
  }
126
125
  return element;
127
126
  }
128
- } catch (error) {
129
- if (isNavigationError(error)) {
130
- console.log(
131
- '⚠️ Navigation detected during waitForLocatorOrElement, recovering gracefully'
132
- );
133
- if (throwOnNavigation) {
134
- throw error;
135
- }
136
- return null;
137
- }
138
- throw error;
127
+ } catch (caught) {
128
+ return recoverWaitError(
129
+ caught,
130
+ options,
131
+ initial,
132
+ 'waitForLocatorOrElement',
133
+ null
134
+ );
139
135
  }
140
136
  }
141
137
 
@@ -179,9 +175,7 @@ export async function waitForVisible(options = {}) {
179
175
  export function locator(options = {}) {
180
176
  const { page, engine, selector } = options;
181
177
 
182
- if (!selector) {
183
- throw new Error('selector is required in options');
184
- }
178
+ requireSelector(selector);
185
179
 
186
180
  if (engine === 'playwright') {
187
181
  return createPlaywrightLocator({ page, selector });