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
@@ -0,0 +1,92 @@
1
+ import { count, isVisible } from './visibility.js';
2
+ import { getLocatorOrElement } from './locators.js';
3
+ import { clickButton } from '../interactions/click.js';
4
+ import { evaluate } from '../utilities/wait.js';
5
+
6
+ /** Return the first selector in caller order with a match (optionally visible). */
7
+ export async function findFirst({
8
+ selectors,
9
+ visible = false,
10
+ ...options
11
+ } = {}) {
12
+ if (!Array.isArray(selectors)) {
13
+ throw new TypeError('selectors must be an array');
14
+ }
15
+ for (const selector of selectors) {
16
+ if (
17
+ (await count({ ...options, selector })) > 0 &&
18
+ (!visible || (await isVisible({ ...options, selector })))
19
+ ) {
20
+ return selector;
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+
26
+ /** Match any phrase in body text; Unicode whitespace includes nonbreaking spaces. */
27
+ export function hasText({
28
+ texts,
29
+ normalizeWhitespace = false,
30
+ ...options
31
+ } = {}) {
32
+ if (!Array.isArray(texts) || texts.some((text) => typeof text !== 'string')) {
33
+ throw new TypeError('texts must be strings');
34
+ }
35
+ return evaluate({
36
+ ...options,
37
+ fn: (phrases, normalize) => {
38
+ const clean = (text) =>
39
+ normalize ? text.replace(/\s+/gu, ' ').trim() : text;
40
+ const body = clean(document.body?.textContent ?? '');
41
+ return phrases.some((text) => body.includes(clean(text)));
42
+ },
43
+ args: [texts, normalizeWhitespace],
44
+ });
45
+ }
46
+
47
+ /** Observe checked state of the selected checkbox or radio. */
48
+ export async function isChecked(options = {}) {
49
+ const element = await getLocatorOrElement(options);
50
+ if (!element) {
51
+ return false;
52
+ }
53
+ const probe = (el) => Boolean(el.checked);
54
+ return options.engine === 'playwright'
55
+ ? element.evaluate(probe)
56
+ : options.page.evaluate(probe, element);
57
+ }
58
+
59
+ /** Idempotently set a checkbox or radio and verify the resulting state. */
60
+ export async function check({ checked = true, ...options } = {}) {
61
+ const element = await getLocatorOrElement(options);
62
+ if (!element) {
63
+ return { checked: false, changed: false, verified: false };
64
+ }
65
+ const type =
66
+ options.engine === 'playwright'
67
+ ? await element.evaluate((el) => el.type)
68
+ : await options.page.evaluate((el) => el.type, element);
69
+ if (!['checkbox', 'radio'].includes(type)) {
70
+ throw new TypeError('check requires a checkbox or radio');
71
+ }
72
+ if (type === 'radio' && !checked) {
73
+ throw new TypeError('A radio cannot be unchecked by clicking');
74
+ }
75
+ const before = await isChecked({ ...options, selector: element });
76
+ if (before === checked) {
77
+ return { checked: before, changed: false, verified: true };
78
+ }
79
+ await clickButton({
80
+ ...options,
81
+ selector: element,
82
+ verify: false,
83
+ waitAfterClick: 0,
84
+ waitForNavigation: false,
85
+ });
86
+ const after = await isChecked({ ...options, selector: element });
87
+ return {
88
+ checked: after,
89
+ changed: before !== after,
90
+ verified: after === checked,
91
+ };
92
+ }
@@ -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,13 +13,11 @@ 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') {
22
- const locator = await getLocatorOrElement({ page, engine, selector });
20
+ const locator = await getLocatorOrElement(options);
23
21
  try {
24
22
  await locator.waitFor({
25
23
  state: 'visible',
@@ -30,7 +28,7 @@ export async function isVisible(options = {}) {
30
28
  return false;
31
29
  }
32
30
  } else {
33
- const element = await getLocatorOrElement({ page, engine, selector });
31
+ const element = await getLocatorOrElement(options);
34
32
  if (!element) {
35
33
  return false;
36
34
  }
@@ -56,27 +54,20 @@ export async function isVisible(options = {}) {
56
54
  * @param {Object} options.page - Browser page object
57
55
  * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
58
56
  * @param {string|Object} options.selector - CSS selector or locator
59
- * @param {Array<string>} options.disabledClasses - Additional CSS classes that indicate disabled state (default: ['magritte-button_loading'])
57
+ * @param {Array<string>} options.disabledClasses - Additional CSS classes that indicate disabled state (default: ['disabled'])
60
58
  * @returns {Promise<boolean>} - True if enabled
61
59
  */
62
60
  export async function isEnabled(options = {}) {
63
- const {
64
- page,
65
- engine,
66
- selector,
67
- disabledClasses = ['magritte-button_loading'],
68
- } = options;
61
+ const { page, engine, selector, disabledClasses = ['disabled'] } = options;
69
62
 
70
- if (!selector) {
71
- throw new Error('selector is required in options');
72
- }
63
+ requireSelector(selector);
73
64
 
74
65
  try {
75
66
  if (engine === 'playwright') {
76
67
  // For Playwright, use locator API
77
68
  const locator =
78
69
  typeof selector === 'string'
79
- ? page.locator(selector).first()
70
+ ? (await getLocatorOrElement(options)).first()
80
71
  : selector;
81
72
  return await locator.evaluate((el, classes) => {
82
73
  const isDisabled =
@@ -87,7 +78,7 @@ export async function isEnabled(options = {}) {
87
78
  }, disabledClasses);
88
79
  } else {
89
80
  // For Puppeteer (selector should already be normalized by withTextSelectorSupport wrapper)
90
- const element = await getLocatorOrElement({ page, engine, selector });
81
+ const element = await getLocatorOrElement(options);
91
82
  if (!element) {
92
83
  return false;
93
84
  }
@@ -124,9 +115,7 @@ export async function isEnabled(options = {}) {
124
115
  export async function count(options = {}) {
125
116
  const { page, engine, selector } = options;
126
117
 
127
- if (!selector) {
128
- throw new Error('selector is required in options');
129
- }
118
+ requireSelector(selector);
130
119
 
131
120
  try {
132
121
  // Handle Puppeteer text selectors
@@ -155,9 +144,37 @@ export async function count(options = {}) {
155
144
  }
156
145
 
157
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
+ }
158
159
  return await page.locator(selector).count();
159
160
  } else {
160
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
+ }
161
178
  return elements.length;
162
179
  }
163
180
  } catch (error) {
package/src/exports.js CHANGED
@@ -328,3 +328,17 @@ export {
328
328
  SafariUnsupportedError,
329
329
  openSafariSettings,
330
330
  } from './browser/safari-support.js';
331
+
332
+ export { BrowserLaunchError } from './browser/launch-diagnostics.js';
333
+
334
+ export { findFirst, hasText, isChecked, check } from './elements/reusable.js';
335
+ export { setCookies, clearCookies } from './browser/session-cookies.js';
336
+ export {
337
+ readFlag,
338
+ uninstallClickListener,
339
+ } from './high-level/universal-logic.js';
340
+
341
+ export {
342
+ findSiteSessions,
343
+ readBrowserCookieSession,
344
+ } from './browser/browser-cookie-session.js';
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
 
@@ -215,16 +206,16 @@ export function makeBrowserCommander(options = {}) {
215
206
  // Subscribe to navigation events (legacy API)
216
207
  onNavigationStart: navigationManager
217
208
  ? (fn) => navigationManager.on('onNavigationStart', fn)
218
- : () => {},
209
+ : () => () => {},
219
210
  onNavigationComplete: navigationManager
220
211
  ? (fn) => navigationManager.on('onNavigationComplete', fn)
221
- : () => {},
212
+ : () => () => {},
222
213
  onUrlChange: navigationManager
223
214
  ? (fn) => navigationManager.on('onUrlChange', fn)
224
- : () => {},
215
+ : () => () => {},
225
216
  onPageReady: navigationManager
226
217
  ? (fn) => navigationManager.on('onPageReady', fn)
227
- : () => {},
218
+ : () => () => {},
228
219
 
229
220
  // Abort handling - check these to stop operations when navigation occurs
230
221
  shouldAbort: navigationManager
@@ -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
+ }
@@ -101,26 +101,29 @@ export async function installClickListener(options = {}) {
101
101
 
102
102
  const result = await safeEvaluate({
103
103
  fn: (text, key) => {
104
- document.addEventListener(
105
- 'click',
106
- (event) => {
107
- let element = event.target;
108
- while (element && element !== document.body) {
109
- const elementText = element.textContent?.trim() || '';
110
- if (
111
- elementText === text ||
112
- ((element.tagName === 'A' || element.tagName === 'BUTTON') &&
113
- elementText.includes(text))
114
- ) {
115
- console.log(`[Click Listener] Detected click on ${text} button!`);
116
- window.sessionStorage.setItem(key, 'true');
117
- break;
118
- }
119
- element = element.parentElement;
104
+ window.__browserCommanderClickListeners ??= new Map();
105
+ const previous = window.__browserCommanderClickListeners.get(key);
106
+ if (previous) {
107
+ document.removeEventListener('click', previous, true);
108
+ }
109
+ const listener = (event) => {
110
+ let element = event.target;
111
+ while (element && element !== document.body) {
112
+ const elementText = element.textContent?.trim() || '';
113
+ if (
114
+ elementText === text ||
115
+ ((element.tagName === 'A' || element.tagName === 'BUTTON') &&
116
+ elementText.includes(text))
117
+ ) {
118
+ console.log(`[Click Listener] Detected click on ${text} button!`);
119
+ window.sessionStorage.setItem(key, 'true');
120
+ break;
120
121
  }
121
- },
122
- true
123
- );
122
+ element = element.parentElement;
123
+ }
124
+ };
125
+ window.__browserCommanderClickListeners.set(key, listener);
126
+ document.addEventListener('click', listener, true);
124
127
  },
125
128
  args: [buttonText, storageKey],
126
129
  });
@@ -176,6 +179,7 @@ export async function findToggleButton(options = {}) {
176
179
  findByText,
177
180
  dataQaSelectors = [],
178
181
  textToFind,
182
+ texts = textToFind ? [textToFind] : [],
179
183
  elementTypes = ['button', 'a', 'span'],
180
184
  } = options;
181
185
 
@@ -188,10 +192,10 @@ export async function findToggleButton(options = {}) {
188
192
  }
189
193
 
190
194
  // Fallback to text search
191
- if (textToFind) {
195
+ for (const text of texts) {
192
196
  for (const elementType of elementTypes) {
193
197
  const selector = await findByText({
194
- text: textToFind,
198
+ text,
195
199
  selector: elementType,
196
200
  });
197
201
  const elemCount = await count({ selector });
@@ -203,3 +207,35 @@ export async function findToggleButton(options = {}) {
203
207
 
204
208
  return null;
205
209
  }
210
+
211
+ /** Read without clearing; interruption is distinct from an unset flag. */
212
+ export async function readFlag({ evaluate, storageKey } = {}) {
213
+ try {
214
+ const set = await evaluate({
215
+ fn: (key) => window.sessionStorage.getItem(key) === 'true',
216
+ args: [storageKey],
217
+ });
218
+ return { status: 'observed', set };
219
+ } catch (error) {
220
+ if (isNavigationError(error)) {
221
+ return { status: 'interrupted', set: null };
222
+ }
223
+ throw error;
224
+ }
225
+ }
226
+
227
+ /** Uninstall only the click listener installed for this storage key. */
228
+ export function uninstallClickListener({ evaluate, storageKey } = {}) {
229
+ return evaluate({
230
+ fn: (key) => {
231
+ const listener = window.__browserCommanderClickListeners?.get(key);
232
+ if (!listener) {
233
+ return false;
234
+ }
235
+ document.removeEventListener('click', listener, true);
236
+ window.__browserCommanderClickListeners.delete(key);
237
+ return true;
238
+ },
239
+ args: [storageKey],
240
+ });
241
+ }
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';
@@ -31,6 +31,25 @@ function isInterrupted(error) {
31
31
  return isNavigationError(error) || isActionStoppedError(error);
32
32
  }
33
33
 
34
+ function waitInterruptedByNavigation({
35
+ error,
36
+ page,
37
+ navigationManager,
38
+ startUrl,
39
+ startSessionId,
40
+ preparationFinished,
41
+ }) {
42
+ const timedOut =
43
+ error.name === 'TimeoutError' ||
44
+ error.name === 'NavigationInterruptedError' ||
45
+ /timeout|timed out/i.test(error.message);
46
+ const navigationChanged =
47
+ page.url() !== startUrl ||
48
+ (startSessionId !== undefined &&
49
+ navigationManager?.getSessionId?.() !== startSessionId);
50
+ return timedOut && navigationChanged && !preparationFinished;
51
+ }
52
+
34
53
  /**
35
54
  * Read the target element's observable state under the click's own budget.
36
55
  *
@@ -709,6 +728,7 @@ async function prepareElement(options = {}) {
709
728
  log,
710
729
  verbose = false,
711
730
  selector,
731
+ index,
712
732
  scrollIntoView: shouldScroll = true,
713
733
  waitAfterScroll,
714
734
  smoothScroll = true,
@@ -719,6 +739,7 @@ async function prepareElement(options = {}) {
719
739
  page,
720
740
  engine,
721
741
  selector,
742
+ index,
722
743
  timeout,
723
744
  });
724
745
 
@@ -973,6 +994,7 @@ export async function clickButton(options = {}) {
973
994
 
974
995
  const startUrl = page.url();
975
996
  const startSessionId = navigationManager?.getSessionId?.();
997
+ let preparationFinished = false;
976
998
 
977
999
  try {
978
1000
  const prepareResult = await prepareElement({
@@ -982,6 +1004,7 @@ export async function clickButton(options = {}) {
982
1004
  log,
983
1005
  verbose,
984
1006
  selector,
1007
+ index: options.index,
985
1008
  scrollIntoView: activationOptions.scroll === CLICK_SCROLL.AUTO,
986
1009
  waitAfterScroll,
987
1010
  smoothScroll,
@@ -1009,6 +1032,7 @@ export async function clickButton(options = {}) {
1009
1032
  });
1010
1033
  }
1011
1034
 
1035
+ preparationFinished = true;
1012
1036
  const clickResult = await clickElement({
1013
1037
  page,
1014
1038
  engine,
@@ -1052,6 +1076,29 @@ export async function clickButton(options = {}) {
1052
1076
  actionId,
1053
1077
  });
1054
1078
  } catch (error) {
1079
+ if (
1080
+ waitInterruptedByNavigation({
1081
+ error,
1082
+ page,
1083
+ navigationManager,
1084
+ startUrl,
1085
+ startSessionId,
1086
+ preparationFinished,
1087
+ })
1088
+ ) {
1089
+ return makeClickResult({
1090
+ status: CLICK_STATUS.INTERRUPTED,
1091
+ dispatched: false,
1092
+ effect: CLICK_EFFECT.NOT_OBSERVED,
1093
+ navigated: true,
1094
+ reason: 'navigation interrupted the element wait before the click',
1095
+ evidence: [
1096
+ evidence('interrupted', { message: error.message, phase: 'prepare' }),
1097
+ ],
1098
+ elapsedMs: elapsed(),
1099
+ actionId,
1100
+ });
1101
+ }
1055
1102
  if (isInterrupted(error)) {
1056
1103
  log.debug(
1057
1104
  () =>