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,503 @@
1
+ /**
2
+ * NavigationManager - Centralized navigation handling
3
+ *
4
+ * This module provides:
5
+ * - Event-based navigation detection
6
+ * - Redirect handling (JS and server-side)
7
+ * - Wait for navigation to complete
8
+ * - Page session management
9
+ */
10
+
11
+ import { isNavigationError } from './navigation-safety.js';
12
+ import { TIMING } from './constants.js';
13
+
14
+ /**
15
+ * Create a NavigationManager instance for a page
16
+ * @param {Object} options - Configuration options
17
+ * @param {Object} options.page - Playwright or Puppeteer page object
18
+ * @param {string} options.engine - 'playwright' or 'puppeteer'
19
+ * @param {Function} options.log - Logger instance
20
+ * @param {Object} options.networkTracker - NetworkTracker instance
21
+ * @returns {Object} - NavigationManager API
22
+ */
23
+ export function createNavigationManager(options = {}) {
24
+ const { page, engine, log, networkTracker } = options;
25
+
26
+ if (!page) {
27
+ throw new Error('page is required in options');
28
+ }
29
+
30
+ // Current state
31
+ let currentUrl = page.url();
32
+ let isNavigating = false;
33
+ let navigationStartTime = null;
34
+ let navigationPromise = null;
35
+ let navigationResolve = null;
36
+
37
+ // Session tracking
38
+ let sessionId = 0;
39
+ let sessionCleanupCallbacks = [];
40
+
41
+ // Abort controller for cancelling operations during navigation
42
+ let currentAbortController = null;
43
+
44
+ // Event listeners
45
+ const listeners = {
46
+ onNavigationStart: [],
47
+ onNavigationComplete: [],
48
+ onBeforeNavigate: [],
49
+ onUrlChange: [],
50
+ onPageReady: [],
51
+ };
52
+
53
+ // Configuration
54
+ const config = {
55
+ redirectStabilizationTime: 1000, // Time to wait for additional redirects
56
+ maxRedirectWait: 60000, // Maximum time to wait for redirects
57
+ networkIdleTimeout: 120000, // Maximum time to wait for network idle (2 minutes for slow connections)
58
+ };
59
+
60
+ /**
61
+ * Handle frame navigation event
62
+ */
63
+ async function handleFrameNavigation(frame) {
64
+ // Only handle main frame
65
+ const mainFrame =
66
+ engine === 'playwright' ? page.mainFrame() : page.mainFrame();
67
+ if (frame !== mainFrame) {
68
+ return;
69
+ }
70
+
71
+ const newUrl = frame.url();
72
+ const previousUrl = currentUrl;
73
+
74
+ if (newUrl === currentUrl) {
75
+ return; // No actual URL change
76
+ }
77
+
78
+ log.debug(() => `🔗 URL change detected: ${previousUrl} → ${newUrl}`);
79
+
80
+ // Notify URL change listeners
81
+ listeners.onUrlChange.forEach((fn) => {
82
+ try {
83
+ fn({ previousUrl, newUrl, sessionId });
84
+ } catch (e) {
85
+ log.debug(() => `⚠️ Error in onUrlChange listener: ${e.message}`);
86
+ }
87
+ });
88
+
89
+ currentUrl = newUrl;
90
+
91
+ // If we're not in a controlled navigation, this is an external navigation
92
+ if (!isNavigating) {
93
+ log.debug(
94
+ () => '🔄 External navigation detected (JS redirect or link click)'
95
+ );
96
+
97
+ // Trigger navigation start
98
+ await triggerNavigationStart({ url: newUrl, isExternal: true });
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Trigger navigation start event
104
+ */
105
+ async function triggerNavigationStart(details = {}) {
106
+ const { url, isExternal = false } = details;
107
+
108
+ // IMPORTANT: Abort any ongoing operations immediately
109
+ // This signals to all running automation that navigation is happening
110
+ if (currentAbortController) {
111
+ log.debug(() => '🛑 Aborting previous operations due to navigation');
112
+ currentAbortController.abort();
113
+ }
114
+ // Create new abort controller for this navigation session
115
+ currentAbortController = new AbortController();
116
+
117
+ // Call beforeNavigate handlers for cleanup
118
+ log.debug(() => '📤 Triggering onBeforeNavigate callbacks...');
119
+ for (const fn of listeners.onBeforeNavigate) {
120
+ try {
121
+ await fn({ currentUrl, sessionId });
122
+ } catch (e) {
123
+ log.debug(() => `⚠️ Error in onBeforeNavigate listener: ${e.message}`);
124
+ }
125
+ }
126
+
127
+ // Run session cleanup callbacks
128
+ log.debug(
129
+ () =>
130
+ `🧹 Running ${sessionCleanupCallbacks.length} session cleanup callbacks...`
131
+ );
132
+ for (const fn of sessionCleanupCallbacks) {
133
+ try {
134
+ await fn();
135
+ } catch (e) {
136
+ log.debug(() => `⚠️ Error in session cleanup: ${e.message}`);
137
+ }
138
+ }
139
+ sessionCleanupCallbacks = [];
140
+
141
+ // Start new session
142
+ sessionId++;
143
+ isNavigating = true;
144
+ navigationStartTime = Date.now();
145
+
146
+ // Reset network tracker for new navigation
147
+ if (networkTracker) {
148
+ networkTracker.reset();
149
+ }
150
+
151
+ // Notify navigation start listeners
152
+ listeners.onNavigationStart.forEach((fn) => {
153
+ try {
154
+ fn({
155
+ url: url || currentUrl,
156
+ sessionId,
157
+ isExternal,
158
+ abortSignal: currentAbortController.signal,
159
+ });
160
+ } catch (e) {
161
+ log.debug(
162
+ () => `⚠️ Error in onNavigationStart listener: ${e.message}`
163
+ );
164
+ }
165
+ });
166
+
167
+ // If external navigation, wait for it to complete
168
+ if (isExternal) {
169
+ await waitForPageReady({ reason: 'external navigation' });
170
+ }
171
+ }
172
+
173
+ // Track if waitForPageReady is currently running to prevent concurrent calls
174
+ let pageReadyPromise = null;
175
+
176
+ /**
177
+ * Wait for page to be ready (DOM loaded + network idle + no redirects)
178
+ * @param {Object} options - Configuration options
179
+ * @param {number} options.timeout - Maximum time to wait
180
+ * @param {string} options.reason - Reason for waiting (for logging)
181
+ * @returns {Promise<boolean>} - True if ready, false if timeout
182
+ */
183
+ async function waitForPageReady(opts = {}) {
184
+ const { timeout = config.networkIdleTimeout, reason = 'page ready' } = opts;
185
+
186
+ // If another waitForPageReady is already running, wait for it instead of starting a new one
187
+ // This prevents concurrent waits that can cause race conditions
188
+ if (pageReadyPromise) {
189
+ log.debug(
190
+ () => `⏳ Waiting for existing page ready operation (${reason})...`
191
+ );
192
+ return pageReadyPromise;
193
+ }
194
+
195
+ log.debug(() => `⏳ Waiting for page ready (${reason})...`);
196
+
197
+ // Create the promise and store it
198
+ pageReadyPromise = (async () => {
199
+ const startTime = Date.now();
200
+ let lastUrlChangeTime = Date.now();
201
+
202
+ // Wait for URL to stabilize (no more redirects)
203
+ while (
204
+ Date.now() - lastUrlChangeTime <
205
+ config.redirectStabilizationTime
206
+ ) {
207
+ if (Date.now() - startTime > timeout) {
208
+ log.debug(
209
+ () => `⚠️ Page ready timeout after ${timeout}ms (${reason})`
210
+ );
211
+ break;
212
+ }
213
+
214
+ await new Promise((r) => setTimeout(r, 200));
215
+
216
+ // Check if URL changed
217
+ const nowUrl = page.url();
218
+ if (nowUrl !== currentUrl) {
219
+ currentUrl = nowUrl;
220
+ lastUrlChangeTime = Date.now();
221
+ log.debug(() => `🔄 Redirect detected: ${nowUrl}`);
222
+ }
223
+ }
224
+
225
+ // Wait for network idle - use remaining time but ensure at least 30s for idle check
226
+ // The 30s idle time is enforced by the network tracker's idleTimeout config
227
+ if (networkTracker) {
228
+ const elapsed = Date.now() - startTime;
229
+ // Give at least 60 seconds for network idle, or remaining time if more
230
+ const remainingTimeout = Math.max(60000, timeout - elapsed);
231
+
232
+ const networkIdle = await networkTracker.waitForNetworkIdle({
233
+ timeout: remainingTimeout,
234
+ // idleTime defaults to TIMING.NAVIGATION_TIMEOUT from tracker config
235
+ });
236
+
237
+ if (!networkIdle) {
238
+ log.debug(() => `⚠️ Network did not become idle (${reason})`);
239
+ }
240
+ }
241
+
242
+ // Complete navigation
243
+ completeNavigation();
244
+
245
+ const elapsed = Date.now() - startTime;
246
+ log.debug(() => `✅ Page ready after ${elapsed}ms (${reason})`);
247
+
248
+ return true;
249
+ })();
250
+
251
+ try {
252
+ return await pageReadyPromise;
253
+ } finally {
254
+ // Clear the promise so next call can start fresh
255
+ pageReadyPromise = null;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Complete current navigation
261
+ */
262
+ function completeNavigation() {
263
+ if (!isNavigating) {
264
+ return;
265
+ }
266
+
267
+ isNavigating = false;
268
+ const duration = Date.now() - navigationStartTime;
269
+ navigationStartTime = null;
270
+
271
+ log.debug(
272
+ () => `✅ Navigation complete (session ${sessionId}, ${duration}ms)`
273
+ );
274
+
275
+ // Notify navigation complete listeners
276
+ listeners.onNavigationComplete.forEach((fn) => {
277
+ try {
278
+ fn({ url: currentUrl, sessionId, duration });
279
+ } catch (e) {
280
+ log.debug(
281
+ () => `⚠️ Error in onNavigationComplete listener: ${e.message}`
282
+ );
283
+ }
284
+ });
285
+
286
+ // Notify page ready listeners
287
+ listeners.onPageReady.forEach((fn) => {
288
+ try {
289
+ fn({ url: currentUrl, sessionId });
290
+ } catch (e) {
291
+ log.debug(() => `⚠️ Error in onPageReady listener: ${e.message}`);
292
+ }
293
+ });
294
+
295
+ // Resolve navigation promise if waiting
296
+ if (navigationResolve) {
297
+ navigationResolve(true);
298
+ navigationResolve = null;
299
+ navigationPromise = null;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Navigate to URL with full wait
305
+ * @param {Object} options - Configuration options
306
+ * @param {string} options.url - URL to navigate to
307
+ * @param {string} options.waitUntil - Playwright/Puppeteer waitUntil option
308
+ * @param {number} options.timeout - Navigation timeout
309
+ * @returns {Promise<boolean>} - True if navigation succeeded
310
+ */
311
+ async function navigate(opts = {}) {
312
+ const { url, waitUntil = 'domcontentloaded', timeout = 60000 } = opts;
313
+
314
+ if (!url) {
315
+ throw new Error('url is required in options');
316
+ }
317
+
318
+ log.debug(() => `🚀 Navigating to: ${url}`);
319
+
320
+ try {
321
+ // Trigger navigation start
322
+ await triggerNavigationStart({ url, isExternal: false });
323
+
324
+ // Perform navigation
325
+ await page.goto(url, { waitUntil, timeout });
326
+
327
+ // Update current URL
328
+ currentUrl = page.url();
329
+
330
+ // Wait for page to be fully ready
331
+ await waitForPageReady({ timeout, reason: 'after goto' });
332
+
333
+ return true;
334
+ } catch (error) {
335
+ if (isNavigationError(error)) {
336
+ log.debug(() => '⚠️ Navigation was interrupted, recovering...');
337
+ completeNavigation();
338
+ return false;
339
+ }
340
+ throw error;
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Wait for any pending navigation to complete
346
+ * @param {Object} options - Configuration options
347
+ * @param {number} options.timeout - Maximum time to wait
348
+ * @returns {Promise<boolean>} - True if navigation completed
349
+ */
350
+ async function waitForNavigation(opts = {}) {
351
+ const { timeout = TIMING.NAVIGATION_TIMEOUT } = opts;
352
+
353
+ if (!isNavigating) {
354
+ return true; // Already ready
355
+ }
356
+
357
+ // Create a promise that resolves when navigation completes
358
+ if (!navigationPromise) {
359
+ navigationPromise = new Promise((resolve) => {
360
+ navigationResolve = resolve;
361
+
362
+ // Timeout handler
363
+ setTimeout(() => {
364
+ if (isNavigating) {
365
+ log.debug(() => '⚠️ waitForNavigation timeout');
366
+ completeNavigation();
367
+ resolve(false);
368
+ }
369
+ }, timeout);
370
+ });
371
+ }
372
+
373
+ return navigationPromise;
374
+ }
375
+
376
+ /**
377
+ * Check if we're currently navigating
378
+ */
379
+ function isCurrentlyNavigating() {
380
+ return isNavigating;
381
+ }
382
+
383
+ /**
384
+ * Get current URL
385
+ */
386
+ function getCurrentUrl() {
387
+ return currentUrl;
388
+ }
389
+
390
+ /**
391
+ * Get current session ID
392
+ */
393
+ function getSessionId() {
394
+ return sessionId;
395
+ }
396
+
397
+ /**
398
+ * Get the current abort signal
399
+ * Use this to check if operations should be aborted due to navigation
400
+ * @returns {AbortSignal|null}
401
+ */
402
+ function getAbortSignal() {
403
+ return currentAbortController ? currentAbortController.signal : null;
404
+ }
405
+
406
+ /**
407
+ * Check if current operation should be aborted (navigation in progress)
408
+ * Returns true if:
409
+ * 1. The current abort controller's signal is aborted, OR
410
+ * 2. Navigation is currently in progress (isNavigating is true)
411
+ * @returns {boolean}
412
+ */
413
+ function shouldAbort() {
414
+ // If we're currently navigating, operations should abort
415
+ if (isNavigating) {
416
+ return true;
417
+ }
418
+ // Also check the abort signal for backwards compatibility
419
+ return currentAbortController
420
+ ? currentAbortController.signal.aborted
421
+ : false;
422
+ }
423
+
424
+ /**
425
+ * Register cleanup callback for current session
426
+ * Will be called before next navigation
427
+ */
428
+ function onSessionCleanup(callback) {
429
+ sessionCleanupCallbacks.push(callback);
430
+ }
431
+
432
+ /**
433
+ * Add event listener
434
+ */
435
+ function on(event, callback) {
436
+ if (listeners[event]) {
437
+ listeners[event].push(callback);
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Remove event listener
443
+ */
444
+ function off(event, callback) {
445
+ if (listeners[event]) {
446
+ const index = listeners[event].indexOf(callback);
447
+ if (index !== -1) {
448
+ listeners[event].splice(index, 1);
449
+ }
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Start listening for navigation events
455
+ */
456
+ function startListening() {
457
+ page.on('framenavigated', handleFrameNavigation);
458
+ log.debug(() => '🔌 Navigation manager started');
459
+ }
460
+
461
+ /**
462
+ * Stop listening for navigation events
463
+ */
464
+ function stopListening() {
465
+ page.off('framenavigated', handleFrameNavigation);
466
+ log.debug(() => '🔌 Navigation manager stopped');
467
+ }
468
+
469
+ /**
470
+ * Update configuration
471
+ */
472
+ function configure(newConfig) {
473
+ Object.assign(config, newConfig);
474
+ }
475
+
476
+ return {
477
+ // Navigation
478
+ navigate,
479
+ waitForNavigation,
480
+ waitForPageReady,
481
+
482
+ // State
483
+ isNavigating: isCurrentlyNavigating,
484
+ getCurrentUrl,
485
+ getSessionId,
486
+
487
+ // Abort handling - use these to stop operations when navigation occurs
488
+ getAbortSignal,
489
+ shouldAbort,
490
+
491
+ // Session management
492
+ onSessionCleanup,
493
+
494
+ // Event listeners
495
+ on,
496
+ off,
497
+
498
+ // Lifecycle
499
+ startListening,
500
+ stopListening,
501
+ configure,
502
+ };
503
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Navigation safety utilities
3
+ * Provides wrappers to handle "Execution context was destroyed" errors gracefully
4
+ */
5
+
6
+ /**
7
+ * Check if an error is a navigation-related error
8
+ * @param {Error} error - The error to check
9
+ * @returns {boolean} - True if this is a navigation error
10
+ */
11
+ export function isNavigationError(error) {
12
+ if (!error || !error.message) {
13
+ return false;
14
+ }
15
+
16
+ const navigationErrorPatterns = [
17
+ 'Execution context was destroyed',
18
+ 'detached Frame',
19
+ 'Target closed',
20
+ 'Session closed',
21
+ 'Protocol error',
22
+ 'Target page, context or browser has been closed',
23
+ 'frame was detached',
24
+ 'Navigating frame was detached',
25
+ 'Cannot find context with specified id',
26
+ 'Attempted to use detached Frame',
27
+ 'Frame was detached',
28
+ 'context was destroyed',
29
+ 'Page crashed',
30
+ ];
31
+
32
+ return navigationErrorPatterns.some((pattern) =>
33
+ error.message.includes(pattern)
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Safe wrapper for async operations that may fail during navigation
39
+ * @param {Function} asyncFn - Async function to execute
40
+ * @param {Object} options - Configuration options
41
+ * @param {any} options.defaultValue - Value to return on navigation error (default: null)
42
+ * @param {string} options.operationName - Name of operation for logging (default: 'operation')
43
+ * @param {boolean} options.silent - Don't log warnings (default: false)
44
+ * @param {Function} options.log - Logger function (optional)
45
+ * @returns {Promise<{success: boolean, value: any, navigationError: boolean}>}
46
+ */
47
+ export async function safeOperation(asyncFn, options = {}) {
48
+ const {
49
+ defaultValue = null,
50
+ operationName = 'operation',
51
+ silent = false,
52
+ log = null,
53
+ } = options;
54
+
55
+ try {
56
+ const value = await asyncFn();
57
+ return { success: true, value, navigationError: false };
58
+ } catch (error) {
59
+ if (isNavigationError(error)) {
60
+ if (!silent) {
61
+ const message = `⚠️ Navigation detected during ${operationName}, recovering gracefully`;
62
+ if (log && typeof log.debug === 'function') {
63
+ log.debug(() => message);
64
+ } else {
65
+ console.log(message);
66
+ }
67
+ }
68
+ return { success: false, value: defaultValue, navigationError: true };
69
+ }
70
+ // Re-throw non-navigation errors
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Create a navigation-safe version of an async function
77
+ * Returns the default value instead of throwing on navigation errors
78
+ * @param {Function} asyncFn - Async function to wrap
79
+ * @param {any} defaultValue - Value to return on navigation error
80
+ * @param {string} operationName - Name for logging
81
+ * @returns {Function} - Wrapped function
82
+ */
83
+ export function makeNavigationSafe(
84
+ asyncFn,
85
+ defaultValue = null,
86
+ operationName = 'operation'
87
+ ) {
88
+ return async (...args) => {
89
+ const result = await safeOperation(() => asyncFn(...args), {
90
+ defaultValue,
91
+ operationName,
92
+ silent: false,
93
+ });
94
+ return result.value;
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Execute an async function with navigation safety, returning result directly
100
+ * Logs warning on navigation error and returns default value
101
+ * @param {Function} asyncFn - Async function to execute
102
+ * @param {any} defaultValue - Value to return on navigation error
103
+ * @param {string} operationName - Name for logging
104
+ * @returns {Promise<any>} - Result or default value
105
+ * @deprecated Use withNavigationSafety (HOF version) instead
106
+ */
107
+ export async function executeWithNavigationSafety(
108
+ asyncFn,
109
+ defaultValue = null,
110
+ operationName = 'operation'
111
+ ) {
112
+ const result = await safeOperation(asyncFn, {
113
+ defaultValue,
114
+ operationName,
115
+ silent: false,
116
+ });
117
+ return result.value;
118
+ }
119
+
120
+ /**
121
+ * Higher-order function that wraps an async function with navigation safety.
122
+ * Returns a new function that handles navigation errors gracefully.
123
+ *
124
+ * @param {Function} fn - Async function to wrap
125
+ * @param {Object} options - Configuration options
126
+ * @param {Function} options.onNavigationError - Callback when navigation error occurs (optional)
127
+ * @param {boolean} options.rethrow - Whether to rethrow navigation errors (default: true)
128
+ * @returns {Function} - Wrapped function with same signature as original
129
+ *
130
+ * @example
131
+ * // Return custom value on navigation error
132
+ * const safeClick = withNavigationSafety(click, {
133
+ * onNavigationError: () => ({ navigated: true }),
134
+ * });
135
+ *
136
+ * @example
137
+ * // Suppress navigation errors (return undefined)
138
+ * const safeCheck = withNavigationSafety(checkElement, {
139
+ * rethrow: false,
140
+ * });
141
+ */
142
+ export function withNavigationSafety(fn, options = {}) {
143
+ const { onNavigationError, rethrow = true } = options;
144
+
145
+ return async (...args) => {
146
+ try {
147
+ return await fn(...args);
148
+ } catch (error) {
149
+ if (isNavigationError(error)) {
150
+ if (onNavigationError) {
151
+ return onNavigationError(error);
152
+ }
153
+ if (!rethrow) {
154
+ return undefined;
155
+ }
156
+ }
157
+ throw error;
158
+ }
159
+ };
160
+ }