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.
- package/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/release.yml +296 -0
- package/.husky/pre-commit +1 -0
- package/.jscpd.json +20 -0
- package/.prettierignore +7 -0
- package/.prettierrc +10 -0
- package/CHANGELOG.md +32 -0
- package/LICENSE +24 -0
- package/README.md +320 -0
- package/bunfig.toml +3 -0
- package/deno.json +7 -0
- package/eslint.config.js +125 -0
- package/examples/react-test-app/index.html +25 -0
- package/examples/react-test-app/package.json +19 -0
- package/examples/react-test-app/src/App.jsx +473 -0
- package/examples/react-test-app/src/main.jsx +10 -0
- package/examples/react-test-app/src/styles.css +323 -0
- package/examples/react-test-app/vite.config.js +9 -0
- package/package.json +89 -0
- package/scripts/changeset-version.mjs +38 -0
- package/scripts/create-github-release.mjs +93 -0
- package/scripts/create-manual-changeset.mjs +86 -0
- package/scripts/format-github-release.mjs +83 -0
- package/scripts/format-release-notes.mjs +216 -0
- package/scripts/instant-version-bump.mjs +121 -0
- package/scripts/merge-changesets.mjs +260 -0
- package/scripts/publish-to-npm.mjs +126 -0
- package/scripts/setup-npm.mjs +37 -0
- package/scripts/validate-changeset.mjs +262 -0
- package/scripts/version-and-commit.mjs +237 -0
- package/src/ARCHITECTURE.md +270 -0
- package/src/README.md +517 -0
- package/src/bindings.js +298 -0
- package/src/browser/launcher.js +93 -0
- package/src/browser/navigation.js +513 -0
- package/src/core/constants.js +24 -0
- package/src/core/engine-adapter.js +466 -0
- package/src/core/engine-detection.js +49 -0
- package/src/core/logger.js +21 -0
- package/src/core/navigation-manager.js +503 -0
- package/src/core/navigation-safety.js +160 -0
- package/src/core/network-tracker.js +373 -0
- package/src/core/page-session.js +299 -0
- package/src/core/page-trigger-manager.js +564 -0
- package/src/core/preferences.js +46 -0
- package/src/elements/content.js +197 -0
- package/src/elements/locators.js +243 -0
- package/src/elements/selectors.js +360 -0
- package/src/elements/visibility.js +166 -0
- package/src/exports.js +121 -0
- package/src/factory.js +192 -0
- package/src/high-level/universal-logic.js +206 -0
- package/src/index.js +17 -0
- package/src/interactions/click.js +684 -0
- package/src/interactions/fill.js +383 -0
- package/src/interactions/scroll.js +341 -0
- package/src/utilities/url.js +33 -0
- package/src/utilities/wait.js +135 -0
- package/tests/e2e/playwright.e2e.test.js +442 -0
- package/tests/e2e/puppeteer.e2e.test.js +408 -0
- package/tests/helpers/mocks.js +542 -0
- package/tests/unit/bindings.test.js +218 -0
- package/tests/unit/browser/navigation.test.js +345 -0
- package/tests/unit/core/constants.test.js +72 -0
- package/tests/unit/core/engine-adapter.test.js +170 -0
- package/tests/unit/core/engine-detection.test.js +81 -0
- package/tests/unit/core/logger.test.js +80 -0
- package/tests/unit/core/navigation-safety.test.js +202 -0
- package/tests/unit/core/network-tracker.test.js +198 -0
- package/tests/unit/core/page-trigger-manager.test.js +358 -0
- package/tests/unit/elements/content.test.js +318 -0
- package/tests/unit/elements/locators.test.js +236 -0
- package/tests/unit/elements/selectors.test.js +302 -0
- package/tests/unit/elements/visibility.test.js +234 -0
- package/tests/unit/factory.test.js +174 -0
- package/tests/unit/high-level/universal-logic.test.js +299 -0
- package/tests/unit/interactions/click.test.js +340 -0
- package/tests/unit/interactions/fill.test.js +378 -0
- package/tests/unit/interactions/scroll.test.js +330 -0
- package/tests/unit/utilities/url.test.js +63 -0
- 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
|
+
}
|