browser-commander 0.2.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 (82) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +11 -0
  3. package/.github/workflows/release.yml +296 -0
  4. package/.husky/pre-commit +1 -0
  5. package/.jscpd.json +20 -0
  6. package/.prettierignore +7 -0
  7. package/.prettierrc +10 -0
  8. package/CHANGELOG.md +32 -0
  9. package/LICENSE +24 -0
  10. package/README.md +320 -0
  11. package/bunfig.toml +3 -0
  12. package/deno.json +7 -0
  13. package/eslint.config.js +125 -0
  14. package/examples/react-test-app/index.html +25 -0
  15. package/examples/react-test-app/package.json +19 -0
  16. package/examples/react-test-app/src/App.jsx +473 -0
  17. package/examples/react-test-app/src/main.jsx +10 -0
  18. package/examples/react-test-app/src/styles.css +323 -0
  19. package/examples/react-test-app/vite.config.js +9 -0
  20. package/package.json +89 -0
  21. package/scripts/changeset-version.mjs +38 -0
  22. package/scripts/create-github-release.mjs +93 -0
  23. package/scripts/create-manual-changeset.mjs +86 -0
  24. package/scripts/format-github-release.mjs +83 -0
  25. package/scripts/format-release-notes.mjs +216 -0
  26. package/scripts/instant-version-bump.mjs +121 -0
  27. package/scripts/merge-changesets.mjs +260 -0
  28. package/scripts/publish-to-npm.mjs +126 -0
  29. package/scripts/setup-npm.mjs +37 -0
  30. package/scripts/validate-changeset.mjs +262 -0
  31. package/scripts/version-and-commit.mjs +237 -0
  32. package/src/ARCHITECTURE.md +270 -0
  33. package/src/README.md +517 -0
  34. package/src/bindings.js +298 -0
  35. package/src/browser/launcher.js +93 -0
  36. package/src/browser/navigation.js +513 -0
  37. package/src/core/constants.js +24 -0
  38. package/src/core/engine-adapter.js +466 -0
  39. package/src/core/engine-detection.js +49 -0
  40. package/src/core/logger.js +21 -0
  41. package/src/core/navigation-manager.js +503 -0
  42. package/src/core/navigation-safety.js +160 -0
  43. package/src/core/network-tracker.js +373 -0
  44. package/src/core/page-session.js +299 -0
  45. package/src/core/page-trigger-manager.js +564 -0
  46. package/src/core/preferences.js +46 -0
  47. package/src/elements/content.js +197 -0
  48. package/src/elements/locators.js +243 -0
  49. package/src/elements/selectors.js +360 -0
  50. package/src/elements/visibility.js +166 -0
  51. package/src/exports.js +121 -0
  52. package/src/factory.js +192 -0
  53. package/src/high-level/universal-logic.js +206 -0
  54. package/src/index.js +17 -0
  55. package/src/interactions/click.js +684 -0
  56. package/src/interactions/fill.js +383 -0
  57. package/src/interactions/scroll.js +341 -0
  58. package/src/utilities/url.js +33 -0
  59. package/src/utilities/wait.js +135 -0
  60. package/tests/e2e/playwright.e2e.test.js +442 -0
  61. package/tests/e2e/puppeteer.e2e.test.js +408 -0
  62. package/tests/helpers/mocks.js +542 -0
  63. package/tests/unit/bindings.test.js +218 -0
  64. package/tests/unit/browser/navigation.test.js +345 -0
  65. package/tests/unit/core/constants.test.js +72 -0
  66. package/tests/unit/core/engine-adapter.test.js +170 -0
  67. package/tests/unit/core/engine-detection.test.js +81 -0
  68. package/tests/unit/core/logger.test.js +80 -0
  69. package/tests/unit/core/navigation-safety.test.js +202 -0
  70. package/tests/unit/core/network-tracker.test.js +198 -0
  71. package/tests/unit/core/page-trigger-manager.test.js +358 -0
  72. package/tests/unit/elements/content.test.js +318 -0
  73. package/tests/unit/elements/locators.test.js +236 -0
  74. package/tests/unit/elements/selectors.test.js +302 -0
  75. package/tests/unit/elements/visibility.test.js +234 -0
  76. package/tests/unit/factory.test.js +174 -0
  77. package/tests/unit/high-level/universal-logic.test.js +299 -0
  78. package/tests/unit/interactions/click.test.js +340 -0
  79. package/tests/unit/interactions/fill.test.js +378 -0
  80. package/tests/unit/interactions/scroll.test.js +330 -0
  81. package/tests/unit/utilities/url.test.js +63 -0
  82. package/tests/unit/utilities/wait.test.js +207 -0
@@ -0,0 +1,684 @@
1
+ import { TIMING } from '../core/constants.js';
2
+ import { isNavigationError } from '../core/navigation-safety.js';
3
+ import { isActionStoppedError } from '../core/page-trigger-manager.js';
4
+ import { waitForLocatorOrElement } from '../elements/locators.js';
5
+ import { scrollIntoViewIfNeeded } from './scroll.js';
6
+ import { logElementInfo } from '../elements/content.js';
7
+ import { createEngineAdapter } from '../core/engine-adapter.js';
8
+
9
+ /**
10
+ * Default verification function for click operations.
11
+ * Verifies that the click had an effect by checking for common patterns:
12
+ * - Element state changes (disabled, aria-pressed, etc.)
13
+ * - Element class changes
14
+ * - Element visibility changes
15
+ *
16
+ * Note: Navigation-triggering clicks are considered "verified" if navigation starts.
17
+ *
18
+ * @param {Object} options - Verification options
19
+ * @param {Object} options.page - Browser page object
20
+ * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
21
+ * @param {Object} options.locatorOrElement - Element that was clicked
22
+ * @param {Object} options.preClickState - State captured before click (optional)
23
+ * @returns {Promise<{verified: boolean, reason: string}>}
24
+ */
25
+ export async function defaultClickVerification(options = {}) {
26
+ const {
27
+ page,
28
+ engine,
29
+ locatorOrElement,
30
+ preClickState = {},
31
+ adapter: providedAdapter,
32
+ } = options;
33
+
34
+ try {
35
+ const adapter = providedAdapter || createEngineAdapter(page, engine);
36
+
37
+ // Get current element state
38
+ const getElementState = async () =>
39
+ await adapter.evaluateOnElement(locatorOrElement, (el) => ({
40
+ disabled: el.disabled,
41
+ ariaPressed: el.getAttribute('aria-pressed'),
42
+ ariaExpanded: el.getAttribute('aria-expanded'),
43
+ ariaSelected: el.getAttribute('aria-selected'),
44
+ checked: el.checked,
45
+ className: el.className,
46
+ isConnected: el.isConnected,
47
+ }));
48
+
49
+ const postClickState = await getElementState();
50
+
51
+ // If we have pre-click state, check for changes
52
+ if (preClickState && Object.keys(preClickState).length > 0) {
53
+ // Check for state changes that indicate click was processed
54
+ if (preClickState.ariaPressed !== postClickState.ariaPressed) {
55
+ return { verified: true, reason: 'aria-pressed changed' };
56
+ }
57
+ if (preClickState.ariaExpanded !== postClickState.ariaExpanded) {
58
+ return { verified: true, reason: 'aria-expanded changed' };
59
+ }
60
+ if (preClickState.ariaSelected !== postClickState.ariaSelected) {
61
+ return { verified: true, reason: 'aria-selected changed' };
62
+ }
63
+ if (preClickState.checked !== postClickState.checked) {
64
+ return { verified: true, reason: 'checked state changed' };
65
+ }
66
+ if (preClickState.className !== postClickState.className) {
67
+ return { verified: true, reason: 'className changed' };
68
+ }
69
+ }
70
+
71
+ // If element is still connected and not disabled, assume click worked
72
+ // (many clicks don't change element state - they trigger actions)
73
+ if (postClickState.isConnected) {
74
+ return {
75
+ verified: true,
76
+ reason: 'element still connected (assumed success)',
77
+ };
78
+ }
79
+
80
+ // Element was removed from DOM - likely click triggered UI change
81
+ return { verified: true, reason: 'element removed from DOM (UI updated)' };
82
+ } catch (error) {
83
+ if (isNavigationError(error) || isActionStoppedError(error)) {
84
+ // Navigation/stop during verification - click likely triggered navigation
85
+ return {
86
+ verified: true,
87
+ reason: 'navigation detected (expected for navigation clicks)',
88
+ navigationError: true,
89
+ };
90
+ }
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Capture element state before click for verification
97
+ * @param {Object} options - Options
98
+ * @param {Object} options.page - Browser page object
99
+ * @param {string} options.engine - Engine type
100
+ * @param {Object} options.locatorOrElement - Element to capture state from
101
+ * @param {Object} options.adapter - Engine adapter (optional, will be created if not provided)
102
+ * @returns {Promise<Object>} - Pre-click state object
103
+ */
104
+ export async function capturePreClickState(options = {}) {
105
+ const { page, engine, locatorOrElement, adapter: providedAdapter } = options;
106
+
107
+ try {
108
+ const adapter = providedAdapter || createEngineAdapter(page, engine);
109
+ return await adapter.evaluateOnElement(locatorOrElement, (el) => ({
110
+ disabled: el.disabled,
111
+ ariaPressed: el.getAttribute('aria-pressed'),
112
+ ariaExpanded: el.getAttribute('aria-expanded'),
113
+ ariaSelected: el.getAttribute('aria-selected'),
114
+ checked: el.checked,
115
+ className: el.className,
116
+ isConnected: el.isConnected,
117
+ }));
118
+ } catch (error) {
119
+ if (isNavigationError(error) || isActionStoppedError(error)) {
120
+ return {};
121
+ }
122
+ throw error;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Verify click operation
128
+ * @param {Object} options - Verification options
129
+ * @param {Object} options.page - Browser page object
130
+ * @param {string} options.engine - Engine type
131
+ * @param {Object} options.locatorOrElement - Element that was clicked
132
+ * @param {Object} options.preClickState - State captured before click
133
+ * @param {Function} options.verifyFn - Custom verification function (optional)
134
+ * @param {Function} options.log - Logger instance
135
+ * @returns {Promise<{verified: boolean, reason: string}>}
136
+ */
137
+ export async function verifyClick(options = {}) {
138
+ const {
139
+ page,
140
+ engine,
141
+ locatorOrElement,
142
+ preClickState = {},
143
+ verifyFn = defaultClickVerification,
144
+ log = { debug: () => {} },
145
+ } = options;
146
+
147
+ const result = await verifyFn({
148
+ page,
149
+ engine,
150
+ locatorOrElement,
151
+ preClickState,
152
+ });
153
+
154
+ if (result.verified) {
155
+ log.debug(() => `✅ Click verification passed: ${result.reason}`);
156
+ } else {
157
+ log.debug(
158
+ () => `⚠️ Click verification uncertain: ${result.reason || 'unknown'}`
159
+ );
160
+ }
161
+
162
+ return result;
163
+ }
164
+
165
+ /**
166
+ * Click an element (low-level)
167
+ * @param {Object} options - Configuration options
168
+ * @param {Object} options.page - Browser page object (required for verification)
169
+ * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
170
+ * @param {Function} options.log - Logger instance
171
+ * @param {Object} options.locatorOrElement - Element or locator to click
172
+ * @param {boolean} options.noAutoScroll - Prevent Playwright's automatic scrolling (default: false)
173
+ * @param {boolean} options.verify - Whether to verify the click operation (default: true)
174
+ * @param {Function} options.verifyFn - Custom verification function (optional)
175
+ * @param {Object} options.adapter - Engine adapter (optional, will be created if not provided)
176
+ * @returns {Promise<{clicked: boolean, verified: boolean, reason?: string}>}
177
+ */
178
+ export async function clickElement(options = {}) {
179
+ const {
180
+ page,
181
+ engine,
182
+ log,
183
+ locatorOrElement,
184
+ noAutoScroll = false,
185
+ verify = true,
186
+ verifyFn,
187
+ adapter: providedAdapter,
188
+ } = options;
189
+
190
+ if (!locatorOrElement) {
191
+ throw new Error('locatorOrElement is required in options');
192
+ }
193
+
194
+ try {
195
+ const adapter = providedAdapter || createEngineAdapter(page, engine);
196
+
197
+ // Capture pre-click state for verification
198
+ let preClickState = {};
199
+ if (verify && page) {
200
+ preClickState = await capturePreClickState({
201
+ page,
202
+ engine,
203
+ locatorOrElement,
204
+ adapter,
205
+ });
206
+ }
207
+
208
+ // Click with appropriate options
209
+ const clickOptions =
210
+ engine === 'playwright' && noAutoScroll ? { force: true } : {};
211
+ if (engine === 'playwright' && noAutoScroll) {
212
+ log.debug(() => `🔍 [VERBOSE] Clicking with noAutoScroll (force: true)`);
213
+ }
214
+ await adapter.click(locatorOrElement, clickOptions);
215
+
216
+ // Verify click if requested
217
+ if (verify && page) {
218
+ const verificationResult = await verifyClick({
219
+ page,
220
+ engine,
221
+ locatorOrElement,
222
+ preClickState,
223
+ verifyFn,
224
+ log,
225
+ });
226
+
227
+ return {
228
+ clicked: true,
229
+ verified: verificationResult.verified,
230
+ reason: verificationResult.reason,
231
+ };
232
+ }
233
+
234
+ return { clicked: true, verified: true };
235
+ } catch (error) {
236
+ if (isNavigationError(error) || isActionStoppedError(error)) {
237
+ console.log(
238
+ '⚠️ Navigation/stop detected during click, recovering gracefully'
239
+ );
240
+ // Navigation during click is considered verified (click triggered navigation)
241
+ return {
242
+ clicked: false,
243
+ verified: true,
244
+ reason: 'navigation during click',
245
+ };
246
+ }
247
+ throw error;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Detect if a click caused navigation by checking URL change or navigation state
253
+ * @param {Object} options - Configuration options
254
+ * @param {Object} options.page - Browser page object
255
+ * @param {Object} options.navigationManager - NavigationManager instance (optional)
256
+ * @param {string} options.startUrl - URL before click
257
+ * @param {Function} options.log - Logger instance
258
+ * @returns {Promise<{navigated: boolean, newUrl: string}>}
259
+ */
260
+ async function detectNavigation(options = {}) {
261
+ const { page, navigationManager, startUrl, log } = options;
262
+
263
+ const currentUrl = page.url();
264
+ const urlChanged = currentUrl !== startUrl;
265
+
266
+ if (navigationManager && navigationManager.isNavigating()) {
267
+ log.debug(() => '🔄 Navigation detected via NavigationManager');
268
+ return { navigated: true, newUrl: currentUrl };
269
+ }
270
+
271
+ if (urlChanged) {
272
+ log.debug(() => `🔄 URL changed: ${startUrl} → ${currentUrl}`);
273
+ return { navigated: true, newUrl: currentUrl };
274
+ }
275
+
276
+ return { navigated: false, newUrl: currentUrl };
277
+ }
278
+
279
+ /**
280
+ * Prepare element for clicking - find, validate, and optionally scroll into view
281
+ * @param {Object} options - Configuration options
282
+ * @param {Object} options.page - Browser page object
283
+ * @param {string} options.engine - Engine type
284
+ * @param {Function} options.wait - Wait function
285
+ * @param {Function} options.log - Logger instance
286
+ * @param {boolean} options.verbose - Enable verbose logging
287
+ * @param {string|Object} options.selector - CSS selector, ElementHandle, or Playwright Locator
288
+ * @param {boolean} options.scrollIntoView - Scroll into view (default: true)
289
+ * @param {number} options.waitAfterScroll - Wait time after scroll in ms
290
+ * @param {boolean} options.smoothScroll - Use smooth scroll animation
291
+ * @param {number} options.timeout - Timeout in ms
292
+ * @returns {Promise<{locatorOrElement: Object, scrolled: boolean, navigated: boolean}>}
293
+ */
294
+ async function prepareElement(options = {}) {
295
+ const {
296
+ page,
297
+ engine,
298
+ wait,
299
+ log,
300
+ verbose = false,
301
+ selector,
302
+ scrollIntoView: shouldScroll = true,
303
+ waitAfterScroll,
304
+ smoothScroll = true,
305
+ timeout,
306
+ } = options;
307
+
308
+ // Get locator/element and wait for it to be visible (unified for both engines)
309
+ const locatorOrElement = await waitForLocatorOrElement({
310
+ page,
311
+ engine,
312
+ selector,
313
+ timeout,
314
+ });
315
+
316
+ // Log element info if verbose
317
+ if (verbose) {
318
+ await logElementInfo({ page, engine, log, locatorOrElement });
319
+ }
320
+
321
+ // Scroll into view (if requested and needed)
322
+ if (shouldScroll) {
323
+ const behavior = smoothScroll ? 'smooth' : 'instant';
324
+ const scrollResult = await scrollIntoViewIfNeeded({
325
+ page,
326
+ engine,
327
+ wait,
328
+ log,
329
+ locatorOrElement,
330
+ behavior,
331
+ waitAfterScroll,
332
+ verify: false, // Don't verify scroll here, we verify the overall click
333
+ });
334
+ // Check if scroll was aborted due to navigation/stop
335
+ if (!scrollResult.skipped && !scrollResult.scrolled) {
336
+ return { locatorOrElement: null, scrolled: false, navigated: true };
337
+ }
338
+ return { locatorOrElement, scrolled: true, navigated: false };
339
+ } else {
340
+ log.debug(() => `🔍 [VERBOSE] Skipping scroll (scrollIntoView: false)`);
341
+ return { locatorOrElement, scrolled: false, navigated: false };
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Execute the click operation with verification
347
+ * @param {Object} options - Configuration options
348
+ * @param {Object} options.page - Browser page object
349
+ * @param {string} options.engine - Engine type
350
+ * @param {Function} options.log - Logger instance
351
+ * @param {Object} options.locatorOrElement - Element or locator to click
352
+ * @param {boolean} options.noAutoScroll - Prevent Playwright's automatic scrolling
353
+ * @param {boolean} options.verify - Whether to verify the click operation
354
+ * @param {Function} options.verifyFn - Custom verification function (optional)
355
+ * @returns {Promise<{clicked: boolean, verified: boolean, reason?: string, navigated: boolean}>}
356
+ */
357
+ async function executeClick(options = {}) {
358
+ const {
359
+ page,
360
+ engine,
361
+ log,
362
+ locatorOrElement,
363
+ noAutoScroll = false,
364
+ verify = true,
365
+ verifyFn,
366
+ } = options;
367
+
368
+ log.debug(() => `🔍 [VERBOSE] About to click element`);
369
+
370
+ const clickResult = await clickElement({
371
+ page,
372
+ engine,
373
+ log,
374
+ locatorOrElement,
375
+ noAutoScroll,
376
+ verify,
377
+ verifyFn,
378
+ });
379
+
380
+ if (!clickResult.clicked) {
381
+ // Navigation/stop occurred during click itself
382
+ return {
383
+ clicked: false,
384
+ verified: true,
385
+ navigated: true,
386
+ reason: 'navigation during click',
387
+ };
388
+ }
389
+
390
+ log.debug(() => `🔍 [VERBOSE] Click completed`);
391
+
392
+ return {
393
+ clicked: true,
394
+ verified: clickResult.verified,
395
+ navigated: false,
396
+ reason: clickResult.reason,
397
+ };
398
+ }
399
+
400
+ /**
401
+ * Handle navigation detection and waiting after a click
402
+ * @param {Object} options - Configuration options
403
+ * @param {Object} options.page - Browser page object
404
+ * @param {Function} options.wait - Wait function
405
+ * @param {Function} options.log - Logger instance
406
+ * @param {Object} options.navigationManager - NavigationManager instance (optional)
407
+ * @param {Object} options.networkTracker - NetworkTracker instance (optional)
408
+ * @param {string} options.startUrl - URL before click
409
+ * @param {boolean} options.waitForNavigation - Wait for navigation to complete
410
+ * @param {number} options.navigationCheckDelay - Time to check if navigation started
411
+ * @param {number} options.waitAfterClick - Wait time after click in ms
412
+ * @returns {Promise<{navigated: boolean, verified: boolean, reason: string}>}
413
+ */
414
+ async function handleNavigationAfterClick(options = {}) {
415
+ const {
416
+ page,
417
+ wait,
418
+ log,
419
+ navigationManager,
420
+ networkTracker,
421
+ startUrl,
422
+ waitForNavigation = true,
423
+ navigationCheckDelay = 500,
424
+ waitAfterClick = 1000,
425
+ } = options;
426
+
427
+ // Check if click caused navigation
428
+ if (waitForNavigation) {
429
+ // Wait briefly for navigation to potentially start
430
+ await wait({
431
+ ms: navigationCheckDelay,
432
+ reason: 'checking for navigation after click',
433
+ });
434
+
435
+ // Detect if navigation occurred
436
+ const { navigated, newUrl } = await detectNavigation({
437
+ page,
438
+ navigationManager,
439
+ startUrl,
440
+ log,
441
+ });
442
+
443
+ if (navigated) {
444
+ log.debug(() => `🔄 Click triggered navigation to: ${newUrl}`);
445
+
446
+ // Wait for page to be fully ready (network idle + no more redirects)
447
+ // Note: If navigationManager detected external navigation, it's already waiting
448
+ // We still call waitForPageReady here to ensure we don't return until page is ready
449
+ if (navigationManager) {
450
+ // Use longer timeout (120s) for full page loads after click-triggered navigation
451
+ await navigationManager.waitForPageReady({
452
+ timeout: 120000,
453
+ reason: 'after click navigation',
454
+ });
455
+ } else if (networkTracker) {
456
+ // Without navigation manager, use network tracker directly with 30s idle time
457
+ await networkTracker.waitForNetworkIdle({
458
+ timeout: 120000,
459
+ // idleTime defaults to 30000ms from tracker config
460
+ });
461
+ } else {
462
+ // Fallback: wait a bit for page to settle
463
+ await wait({ ms: 2000, reason: 'page settle after navigation' });
464
+ }
465
+
466
+ // Navigation is considered successful verification
467
+ return {
468
+ navigated: true,
469
+ verified: true,
470
+ reason: 'click triggered navigation',
471
+ };
472
+ }
473
+ }
474
+
475
+ // No navigation - wait after click if specified (useful for modals)
476
+ if (waitAfterClick > 0) {
477
+ const waitResult = await wait({
478
+ ms: waitAfterClick,
479
+ reason: 'post-click settling time for modal scroll capture',
480
+ });
481
+
482
+ // Check if wait was aborted due to navigation that happened during the wait
483
+ if (waitResult && waitResult.aborted) {
484
+ log.debug(
485
+ () => '🔄 Navigation detected during post-click wait (wait was aborted)'
486
+ );
487
+
488
+ // Re-check for navigation since it happened during the wait
489
+ const { navigated: lateNavigated, newUrl: lateUrl } =
490
+ await detectNavigation({
491
+ page,
492
+ navigationManager,
493
+ startUrl,
494
+ log,
495
+ });
496
+
497
+ if (lateNavigated) {
498
+ log.debug(() => `🔄 Confirmed late navigation to: ${lateUrl}`);
499
+
500
+ // Wait for page to be fully ready
501
+ if (navigationManager) {
502
+ await navigationManager.waitForPageReady({
503
+ timeout: 120000,
504
+ reason: 'after late-detected click navigation',
505
+ });
506
+ }
507
+
508
+ return {
509
+ navigated: true,
510
+ verified: true,
511
+ reason: 'late-detected navigation',
512
+ };
513
+ }
514
+ }
515
+ }
516
+
517
+ // Final check: did navigation happen while we were processing?
518
+ // This catches cases where navigation started but wasn't detected earlier
519
+ if (navigationManager && navigationManager.shouldAbort()) {
520
+ log.debug(
521
+ () => '🔄 Navigation detected via abort signal at end of click processing'
522
+ );
523
+
524
+ await navigationManager.waitForPageReady({
525
+ timeout: 120000,
526
+ reason: 'after abort-detected click navigation',
527
+ });
528
+
529
+ return {
530
+ navigated: true,
531
+ verified: true,
532
+ reason: 'abort-signal navigation',
533
+ };
534
+ }
535
+
536
+ // If we have network tracking, wait for any XHR/fetch to complete
537
+ // Use shorter idle time for non-navigation clicks (just waiting for XHR, not full page load)
538
+ if (networkTracker) {
539
+ await networkTracker.waitForNetworkIdle({
540
+ timeout: 10000, // Maximum wait time
541
+ idleTime: 2000, // Only 2 seconds of idle needed for XHR completion
542
+ });
543
+ }
544
+
545
+ return { navigated: false, verified: true, reason: 'no navigation detected' };
546
+ }
547
+
548
+ /**
549
+ * Click a button or element (high-level with scrolling and waits)
550
+ * Now navigation-aware - automatically waits for page ready after navigation-causing clicks.
551
+ *
552
+ * @param {Object} options - Configuration options
553
+ * @param {Object} options.page - Browser page object
554
+ * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
555
+ * @param {Function} options.wait - Wait function
556
+ * @param {Function} options.log - Logger instance
557
+ * @param {boolean} options.verbose - Enable verbose logging
558
+ * @param {Object} options.navigationManager - NavigationManager instance (optional)
559
+ * @param {Object} options.networkTracker - NetworkTracker instance (optional)
560
+ * @param {string|Object} options.selector - CSS selector, ElementHandle, or Playwright Locator
561
+ * @param {boolean} options.scrollIntoView - Scroll into view (default: true)
562
+ * @param {number} options.waitAfterScroll - Wait time after scroll in ms (default: TIMING.DEFAULT_WAIT_AFTER_SCROLL)
563
+ * @param {boolean} options.smoothScroll - Use smooth scroll animation (default: true)
564
+ * @param {number} options.waitAfterClick - Wait time after click in ms (default: 1000). Gives modals time to capture scroll position before opening
565
+ * @param {boolean} options.waitForNavigation - Wait for navigation to complete if click causes navigation (default: true)
566
+ * @param {number} options.navigationCheckDelay - Time to check if navigation started (default: 500ms)
567
+ * @param {number} options.timeout - Timeout in ms (default: TIMING.DEFAULT_TIMEOUT)
568
+ * @param {boolean} options.verify - Whether to verify the click operation (default: true)
569
+ * @param {Function} options.verifyFn - Custom verification function (optional)
570
+ * @returns {Promise<{clicked: boolean, navigated: boolean, verified: boolean, reason?: string}>}
571
+ * - clicked: true if click was performed
572
+ * - navigated: true if click caused navigation
573
+ * - verified: true if click was verified (navigation counts as verification)
574
+ * @throws {Error} - If selector is missing, element not found, or click operation fails (except navigation/stop errors)
575
+ */
576
+ export async function clickButton(options = {}) {
577
+ const {
578
+ page,
579
+ engine,
580
+ wait,
581
+ log,
582
+ verbose = false,
583
+ navigationManager,
584
+ networkTracker,
585
+ selector,
586
+ scrollIntoView: shouldScroll = true,
587
+ waitAfterScroll = TIMING.DEFAULT_WAIT_AFTER_SCROLL,
588
+ smoothScroll = true,
589
+ waitAfterClick = 1000,
590
+ waitForNavigation = true,
591
+ navigationCheckDelay = 500,
592
+ timeout = TIMING.DEFAULT_TIMEOUT,
593
+ verify = true,
594
+ verifyFn,
595
+ } = options;
596
+
597
+ if (!selector) {
598
+ throw new Error('clickButton: selector is required in options');
599
+ }
600
+
601
+ // Record URL before click for navigation detection
602
+ const startUrl = page.url();
603
+
604
+ try {
605
+ // Step 1: Prepare element (find, validate, scroll into view)
606
+ const prepareResult = await prepareElement({
607
+ page,
608
+ engine,
609
+ wait,
610
+ log,
611
+ verbose,
612
+ selector,
613
+ scrollIntoView: shouldScroll,
614
+ waitAfterScroll,
615
+ smoothScroll,
616
+ timeout,
617
+ });
618
+
619
+ if (prepareResult.navigated) {
620
+ return {
621
+ clicked: false,
622
+ navigated: true,
623
+ verified: true,
624
+ reason: 'navigation during scroll',
625
+ };
626
+ }
627
+
628
+ const { locatorOrElement } = prepareResult;
629
+
630
+ // Step 2: Execute click operation
631
+ const clickResult = await executeClick({
632
+ page,
633
+ engine,
634
+ log,
635
+ locatorOrElement,
636
+ noAutoScroll: !shouldScroll,
637
+ verify,
638
+ verifyFn,
639
+ });
640
+
641
+ if (clickResult.navigated) {
642
+ return {
643
+ clicked: false,
644
+ navigated: true,
645
+ verified: true,
646
+ reason: 'navigation during click',
647
+ };
648
+ }
649
+
650
+ // Step 3: Handle navigation detection and waiting
651
+ const navResult = await handleNavigationAfterClick({
652
+ page,
653
+ wait,
654
+ log,
655
+ navigationManager,
656
+ networkTracker,
657
+ startUrl,
658
+ waitForNavigation,
659
+ navigationCheckDelay,
660
+ waitAfterClick,
661
+ });
662
+
663
+ return {
664
+ clicked: true,
665
+ navigated: navResult.navigated,
666
+ verified: clickResult.verified && navResult.verified,
667
+ reason: navResult.navigated ? navResult.reason : clickResult.reason,
668
+ };
669
+ } catch (error) {
670
+ if (isNavigationError(error) || isActionStoppedError(error)) {
671
+ console.log(
672
+ '⚠️ Navigation/stop detected during clickButton, recovering gracefully'
673
+ );
674
+ // Navigation/stop during click is considered successful
675
+ return {
676
+ clicked: false,
677
+ navigated: true,
678
+ verified: true,
679
+ reason: 'navigation/stop error',
680
+ };
681
+ }
682
+ throw error;
683
+ }
684
+ }