browser-commander 0.27.0 → 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 (75) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +4 -0
  3. package/eslint.config.js +11 -0
  4. package/package.json +5 -2
  5. package/src/browser/connector.js +117 -13
  6. package/src/browser/launch-options.js +2 -1
  7. package/src/browser/launch-restrictions.json +33 -2
  8. package/src/browser/launcher.js +26 -1
  9. package/src/browser/persistent-session.js +360 -0
  10. package/src/browser/profile-directory.js +20 -0
  11. package/src/browser/real-browser.js +7 -2
  12. package/src/browser/restrictions.js +13 -4
  13. package/src/browser/session-watchdog.js +71 -0
  14. package/src/browser/session-worker.js +70 -0
  15. package/src/browser/webdriver.js +4 -0
  16. package/src/capture/encoding.js +327 -0
  17. package/src/capture/errors.js +12 -0
  18. package/src/capture/files.js +9 -0
  19. package/src/capture/index.js +5 -0
  20. package/src/capture/recording.js +136 -0
  21. package/src/capture/screenshot.js +222 -0
  22. package/src/capture/video.js +186 -0
  23. package/src/capture/viewport.js +68 -0
  24. package/src/capture/worker.js +22 -0
  25. package/src/cli/args.js +66 -1
  26. package/src/cli/commands.js +113 -2
  27. package/src/cli/high-level-methods.js +40 -3
  28. package/src/cli/sessions.js +11 -3
  29. package/src/core/commander-features.js +105 -0
  30. package/src/core/constants.js +1 -0
  31. package/src/core/navigation-manager.js +4 -2
  32. package/src/core/navigation-readiness.js +30 -2
  33. package/src/core/navigation-safety.js +51 -0
  34. package/src/core/page-trigger-manager.js +115 -30
  35. package/src/core/readiness.js +10 -2
  36. package/src/elements/locators.js +26 -32
  37. package/src/elements/selectors.js +37 -81
  38. package/src/elements/visibility.js +32 -10
  39. package/src/factory.js +13 -12
  40. package/src/index.js +2 -0
  41. package/src/interactions/click.js +44 -0
  42. package/src/interactions/overlays.js +28 -0
  43. package/src/traces/bundle.js +14 -5
  44. package/src/traces/checkpoints.js +99 -0
  45. package/src/traces/links.js +117 -1
  46. package/src/traces/mutation-stream.js +1 -0
  47. package/src/traces/network.js +257 -0
  48. package/src/traces/page-capture.js +143 -52
  49. package/src/traces/reader.js +8 -10
  50. package/src/traces/recorder.js +221 -245
  51. package/src/traces/render.js +168 -0
  52. package/src/traces/rolling.js +180 -0
  53. package/src/traces/start-cleanup.js +15 -0
  54. package/src/traces/stop.js +159 -0
  55. package/src/traces/storage.js +43 -0
  56. package/src/utilities/subprocess.js +32 -0
  57. package/tests/e2e/cli.e2e.test.js +20 -1
  58. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  59. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  60. package/tests/unit/browser/launcher.test.js +5 -1
  61. package/tests/unit/browser/persistent-session.test.js +59 -0
  62. package/tests/unit/browser/quiet-ui.test.js +16 -0
  63. package/tests/unit/browser/tab-selection.test.js +38 -0
  64. package/tests/unit/capture/capture.test.js +52 -0
  65. package/tests/unit/capture/cleanup.test.js +24 -0
  66. package/tests/unit/capture/viewport.test.js +168 -0
  67. package/tests/unit/cli/dispatcher.test.js +25 -0
  68. package/tests/unit/core/readiness.test.js +21 -0
  69. package/tests/unit/core/trigger-readiness.test.js +39 -0
  70. package/tests/unit/elements/visible-count.test.js +24 -0
  71. package/tests/unit/elements/wait-navigation.test.js +26 -0
  72. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  73. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  74. package/tests/unit/traces/bundle.test.js +16 -0
  75. package/tests/unit/traces/network.test.js +97 -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,9 @@ 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();
183
185
 
184
186
  /**
185
187
  * Register a page trigger
@@ -191,7 +193,24 @@ export function createPageTriggerManager(options = {}) {
191
193
  * @returns {Function} - Unregister function
192
194
  */
193
195
  function pageTrigger(config) {
194
- 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
+ }
195
214
 
196
215
  if (typeof condition !== 'function') {
197
216
  throw new Error('condition must be a function');
@@ -205,6 +224,8 @@ export function createPageTriggerManager(options = {}) {
205
224
  action,
206
225
  name,
207
226
  priority,
227
+ readyOn,
228
+ concurrency,
208
229
  };
209
230
 
210
231
  triggers.push(triggerConfig);
@@ -234,7 +255,7 @@ export function createPageTriggerManager(options = {}) {
234
255
  function findMatchingTrigger(ctx) {
235
256
  for (const config of triggers) {
236
257
  try {
237
- if (config.condition(ctx)) {
258
+ if (config.readyOn === ctx.readyOn && config.condition(ctx)) {
238
259
  return config;
239
260
  }
240
261
  } catch (e) {
@@ -278,8 +299,9 @@ export function createPageTriggerManager(options = {}) {
278
299
 
279
300
  // Wait for action to finish (with timeout)
280
301
  const timeoutMs = 10000; // 10 second max wait
302
+ let stopTimer;
281
303
  const timeoutPromise = new Promise((resolve) => {
282
- setTimeout(() => {
304
+ stopTimer = setTimeout(() => {
283
305
  log.debug(
284
306
  () =>
285
307
  `⚠️ Action "${currentTrigger?.name}" did not stop gracefully within ${timeoutMs}ms`
@@ -289,6 +311,16 @@ export function createPageTriggerManager(options = {}) {
289
311
  });
290
312
 
291
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
+ }
292
324
 
293
325
  // Cleanup
294
326
  isActionRunning = false;
@@ -312,11 +344,12 @@ export function createPageTriggerManager(options = {}) {
312
344
  * @param {string} url - URL to start action for
313
345
  * @param {Object} commander - BrowserCommander instance
314
346
  */
315
- function startAction(url, commander) {
347
+ function startAction(url, commander, readyOn = 'networkidle') {
316
348
  // Create context for condition checking
317
349
  const conditionCtx = {
318
350
  url,
319
351
  commander,
352
+ readyOn,
320
353
  };
321
354
 
322
355
  // Find matching trigger
@@ -325,6 +358,16 @@ export function createPageTriggerManager(options = {}) {
325
358
  log.debug(() => `📋 No trigger registered for: ${url}`);
326
359
  return Promise.resolve();
327
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
+ }
328
371
 
329
372
  log.debug(() => `🚀 Starting action "${matchingTrigger.name}" for: ${url}`);
330
373
 
@@ -510,36 +553,32 @@ export function createPageTriggerManager(options = {}) {
510
553
  /**
511
554
  * Handle page ready - start matching action
512
555
  */
513
- async function onPageReady({ url }, commander) {
514
- await startAction(url, commander);
515
- }
516
-
517
556
  /**
518
557
  * Check if an action is currently running
519
558
  */
520
- function isRunning() {
521
- return isActionRunning;
522
- }
559
+ const isRunning = () => isActionRunning;
523
560
 
524
561
  /**
525
562
  * Get current trigger name
526
563
  */
527
- function getCurrentTriggerName() {
528
- return currentTrigger?.name || null;
529
- }
564
+ const getCurrentTriggerName = () => currentTrigger?.name || null;
530
565
 
531
566
  /**
532
567
  * Initialize - connect to navigation manager
533
568
  * @param {Object} commander - BrowserCommander instance
534
569
  */
535
570
  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
- );
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
+ });
543
582
 
544
583
  log.debug(() => '📋 PageTriggerManager initialized');
545
584
  }
@@ -547,12 +586,13 @@ export function createPageTriggerManager(options = {}) {
547
586
  /**
548
587
  * Cleanup
549
588
  */
550
- async function destroy() {
551
- await stopCurrentAction();
552
- triggers.length = 0;
553
- navigationManager.off('onBeforeNavigate', onNavigationStart);
554
- log.debug(() => '📋 PageTriggerManager destroyed');
555
- }
589
+ const destroy = createTriggerDestroy({
590
+ subscriptions,
591
+ getQueue: () => startQueue,
592
+ stopCurrentAction,
593
+ triggers,
594
+ log,
595
+ });
556
596
 
557
597
  return {
558
598
  pageTrigger,
@@ -567,3 +607,48 @@ export function createPageTriggerManager(options = {}) {
567
607
  isActionStoppedError,
568
608
  };
569
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
+ }
@@ -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 });
@@ -1,5 +1,9 @@
1
- import { isNavigationError } from '../core/navigation-safety.js';
2
- import { createPlaywrightLocator } from './locators.js';
1
+ import {
2
+ isNavigationError,
3
+ waitNavigationState,
4
+ recoverWaitError,
5
+ } from '../core/navigation-safety.js';
6
+ import { createPlaywrightLocator, requireSelector } from './locators.js';
3
7
 
4
8
  /**
5
9
  * Query single element
@@ -9,67 +13,38 @@ import { createPlaywrightLocator } from './locators.js';
9
13
  * @param {string} options.selector - CSS selector
10
14
  * @returns {Promise<Object|null>} - Element handle or null
11
15
  */
12
- export async function querySelector(options = {}) {
13
- const { page, engine, selector } = options;
14
-
15
- if (!selector) {
16
- throw new Error('selector is required in options');
17
- }
16
+ export function querySelector(options = {}) {
17
+ return queryElements(options, true);
18
+ }
18
19
 
19
- try {
20
- if (engine === 'playwright') {
21
- const locator = createPlaywrightLocator({ page, selector }).first();
22
- const count = await locator.count();
23
- return count > 0 ? locator : null;
24
- } else {
25
- return await page.$(selector);
26
- }
27
- } catch (error) {
28
- if (isNavigationError(error)) {
29
- console.log(
30
- '⚠️ Navigation detected during querySelector, returning null'
31
- );
32
- return null;
33
- }
34
- throw error;
35
- }
20
+ /** Return every matching element with the same navigation recovery as querySelector. */
21
+ export function querySelectorAll(options = {}) {
22
+ return queryElements(options, false);
36
23
  }
37
24
 
38
- /**
39
- * Query all elements
40
- * @param {Object} options - Configuration options
41
- * @param {Object} options.page - Browser page object
42
- * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
43
- * @param {string} options.selector - CSS selector
44
- * @returns {Promise<Array>} - Array of element handles
45
- */
46
- export async function querySelectorAll(options = {}) {
25
+ async function queryElements(options, single) {
26
+ requireSelector(options.selector);
47
27
  const { page, engine, selector } = options;
48
-
49
- if (!selector) {
50
- throw new Error('selector is required in options');
51
- }
52
-
53
28
  try {
54
- if (engine === 'playwright') {
55
- const locator = createPlaywrightLocator({ page, selector });
56
- const count = await locator.count();
57
- const elements = [];
58
- for (let i = 0; i < count; i++) {
59
- elements.push(locator.nth(i));
60
- }
61
- return elements;
62
- } else {
63
- return await page.$$(selector);
29
+ if (engine !== 'playwright') {
30
+ return single ? await page.$(selector) : await page.$$(selector);
64
31
  }
32
+ const matching = createPlaywrightLocator({ page, selector });
33
+ const locator = single ? matching.first() : matching;
34
+ const total = await locator.count();
35
+ return single
36
+ ? total > 0
37
+ ? locator
38
+ : null
39
+ : Array.from({ length: total }, (_, index) => locator.nth(index));
65
40
  } catch (error) {
66
- if (isNavigationError(error)) {
67
- console.log(
68
- '⚠️ Navigation detected during querySelectorAll, returning empty array'
69
- );
70
- return [];
41
+ if (!isNavigationError(error)) {
42
+ throw error;
71
43
  }
72
- throw error;
44
+ console.log(
45
+ `⚠️ Navigation detected during ${single ? 'querySelector' : 'querySelectorAll'}, recovering gracefully`
46
+ );
47
+ return single ? null : [];
73
48
  }
74
49
  }
75
50
 
@@ -175,9 +150,7 @@ function parsePlaywrightTextSelector(selector) {
175
150
  export async function normalizeSelector(options = {}) {
176
151
  const { page, engine, selector } = options;
177
152
 
178
- if (!selector) {
179
- throw new Error('selector is required in options');
180
- }
153
+ requireSelector(selector);
181
154
 
182
155
  // DEFENSIVE: Validate selector type - must be string or Puppeteer text selector object
183
156
  // Arrays and other invalid types should be rejected to prevent downstream querySelectorAll errors
@@ -360,19 +333,11 @@ export function withTextSelectorSupport(fn, engine, page) {
360
333
  * @returns {Promise<boolean>} - True if selector found, false on navigation
361
334
  */
362
335
  export async function waitForSelector(options = {}) {
363
- const {
364
- page,
365
- engine,
366
- selector,
367
- visible = true,
368
- timeout = 5000,
369
- throwOnNavigation = true,
370
- } = options;
371
-
372
- if (!selector) {
373
- throw new Error('selector is required in options');
374
- }
336
+ const { page, engine, selector, visible = true, timeout = 5000 } = options;
337
+
338
+ requireSelector(selector);
375
339
 
340
+ const initial = waitNavigationState(page, options.navigationManager);
376
341
  try {
377
342
  if (engine === 'playwright') {
378
343
  const locator = createPlaywrightLocator({ page, selector });
@@ -384,16 +349,7 @@ export async function waitForSelector(options = {}) {
384
349
  await page.waitForSelector(selector, { visible, timeout });
385
350
  }
386
351
  return true;
387
- } catch (error) {
388
- if (isNavigationError(error)) {
389
- console.log(
390
- '⚠️ Navigation detected during waitForSelector, recovering gracefully'
391
- );
392
- if (throwOnNavigation) {
393
- throw error;
394
- }
395
- return false;
396
- }
397
- throw error;
352
+ } catch (caught) {
353
+ return recoverWaitError(caught, options, initial, 'waitForSelector', false);
398
354
  }
399
355
  }
@@ -1,6 +1,6 @@
1
1
  import { TIMING } from '../core/constants.js';
2
2
  import { isNavigationError } from '../core/navigation-safety.js';
3
- import { getLocatorOrElement } from './locators.js';
3
+ import { getLocatorOrElement, requireSelector } from './locators.js';
4
4
 
5
5
  /**
6
6
  * Check if element is visible
@@ -13,9 +13,7 @@ import { getLocatorOrElement } from './locators.js';
13
13
  export async function isVisible(options = {}) {
14
14
  const { page, engine, selector } = options;
15
15
 
16
- if (!selector) {
17
- throw new Error('selector is required in options');
18
- }
16
+ requireSelector(selector);
19
17
 
20
18
  try {
21
19
  if (engine === 'playwright') {
@@ -62,9 +60,7 @@ export async function isVisible(options = {}) {
62
60
  export async function isEnabled(options = {}) {
63
61
  const { page, engine, selector, disabledClasses = ['disabled'] } = options;
64
62
 
65
- if (!selector) {
66
- throw new Error('selector is required in options');
67
- }
63
+ requireSelector(selector);
68
64
 
69
65
  try {
70
66
  if (engine === 'playwright') {
@@ -119,9 +115,7 @@ export async function isEnabled(options = {}) {
119
115
  export async function count(options = {}) {
120
116
  const { page, engine, selector } = options;
121
117
 
122
- if (!selector) {
123
- throw new Error('selector is required in options');
124
- }
118
+ requireSelector(selector);
125
119
 
126
120
  try {
127
121
  // Handle Puppeteer text selectors
@@ -150,9 +144,37 @@ export async function count(options = {}) {
150
144
  }
151
145
 
152
146
  if (engine === 'playwright') {
147
+ if (options.visible) {
148
+ return await page
149
+ .locator(selector)
150
+ .evaluateAll(
151
+ (elements) =>
152
+ elements.filter(
153
+ (element) =>
154
+ element.getClientRects().length > 0 &&
155
+ element.checkVisibility({ visibilityProperty: true })
156
+ ).length
157
+ );
158
+ }
153
159
  return await page.locator(selector).count();
154
160
  } else {
155
161
  const elements = await page.$$(selector);
162
+ if (options.visible) {
163
+ let visible = 0;
164
+ for (const element of elements) {
165
+ if (
166
+ await page.evaluate(
167
+ (element) =>
168
+ element.getClientRects().length > 0 &&
169
+ element.checkVisibility({ visibilityProperty: true }),
170
+ element
171
+ )
172
+ ) {
173
+ visible += 1;
174
+ }
175
+ }
176
+ return visible;
177
+ }
156
178
  return elements.length;
157
179
  }
158
180
  } catch (error) {
package/src/factory.js CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  } from './core/page-trigger-manager.js';
23
23
  import { createBoundFunctions } from './bindings.js';
24
24
  import { attachDownloads } from './downloads/attach.js';
25
- import { startTrace } from './traces/recorder.js';
25
+ import { attachCommanderFeatures } from './core/commander-features.js';
26
26
  import { createCdpSession } from './browser/cdp-session.js';
27
27
 
28
28
  /**
@@ -66,7 +66,7 @@ export function makeBrowserCommander(options = {}) {
66
66
  page,
67
67
  engine,
68
68
  log,
69
- idleTimeout: 30000, // Wait 30 seconds without requests before considering network idle
69
+ idleTimeout: options.networkIdleForMs ?? 30000, // Wait 30 seconds without requests before considering network idle
70
70
  });
71
71
  networkTracker.startTracking();
72
72
  }
@@ -92,6 +92,7 @@ export function makeBrowserCommander(options = {}) {
92
92
  log,
93
93
  networkTracker,
94
94
  });
95
+ navigationManager.configure(navigationOptions(options));
95
96
  navigationManager.startListening();
96
97
 
97
98
  // Create PageSession factory
@@ -159,7 +160,6 @@ export function makeBrowserCommander(options = {}) {
159
160
 
160
161
  // All bound functions
161
162
  ...boundFunctions,
162
-
163
163
  // Managed downloads (issue #88). Present once a manager is attached,
164
164
  // either by passing the one launchBrowser() returned or by calling
165
165
  // configureDownloads() on an existing commander.
@@ -192,15 +192,6 @@ export function makeBrowserCommander(options = {}) {
192
192
  */
193
193
  createCdpSession: () => createCdpSession(page, { engine }),
194
194
 
195
- /**
196
- * Start recording a privacy-aware trace of this session (issue #87).
197
- *
198
- * @param {Object} [traceOptions] - {output, mode, screenshots, dom, events, privacy, limits}
199
- * @returns {Promise<Object>} The running trace: {checkpoint, event, stop}
200
- */
201
- startTrace: (traceOptions = {}) =>
202
- startTrace({ commander, page, log, ...traceOptions }),
203
-
204
195
  // Lifecycle
205
196
  destroy,
206
197
 
@@ -295,5 +286,15 @@ export function makeBrowserCommander(options = {}) {
295
286
  pageTriggerManager.initialize(commander);
296
287
  }
297
288
 
289
+ attachCommanderFeatures(commander, options, makeBrowserCommander);
298
290
  return commander;
299
291
  }
292
+
293
+ function navigationOptions(options) {
294
+ return {
295
+ ...(options.navigationManager ?? {}),
296
+ ...(options.networkIdleTimeout === undefined
297
+ ? {}
298
+ : { networkIdleTimeout: options.networkIdleTimeout }),
299
+ };
300
+ }
package/src/index.js CHANGED
@@ -18,3 +18,5 @@ export {
18
18
 
19
19
  // Re-export all public APIs from exports module
20
20
  export * from './exports.js';
21
+ export * from './capture/index.js';
22
+ export { connectOrLaunch } from './browser/persistent-session.js';