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.
- package/.jscpd-baseline.json +24 -23
- package/CHANGELOG.md +22 -0
- package/README.md +6 -0
- package/eslint.config.js +11 -0
- package/package.json +9 -4
- package/src/bindings.js +19 -0
- package/src/browser/attach/snapshot-launch.js +8 -6
- package/src/browser/attach/snapshot.js +39 -2
- package/src/browser/browser-cookie-database.js +71 -2
- package/src/browser/browser-cookie-session.js +183 -0
- package/src/browser/browser-cookies.js +29 -0
- package/src/browser/connector.js +117 -13
- package/src/browser/debugging-port.js +4 -2
- package/src/browser/default-browser.js +33 -9
- package/src/browser/launch-diagnostics.js +102 -0
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +54 -9
- package/src/browser/migration/sqlite-snapshot.js +5 -27
- package/src/browser/navigation.js +173 -98
- package/src/browser/persistent-session.js +360 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +142 -70
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-cookies.js +78 -0
- package/src/browser/session-persistence.js +72 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +70 -0
- package/src/browser/storage-state.js +32 -6
- package/src/browser/webdriver.js +4 -0
- package/src/capture/encoding.js +327 -0
- package/src/capture/errors.js +12 -0
- package/src/capture/files.js +9 -0
- package/src/capture/index.js +5 -0
- package/src/capture/recording.js +136 -0
- package/src/capture/screenshot.js +222 -0
- package/src/capture/video.js +186 -0
- package/src/capture/viewport.js +68 -0
- package/src/capture/worker.js +22 -0
- package/src/cli/args.js +66 -1
- package/src/cli/commands.js +113 -2
- package/src/cli/high-level-methods.js +40 -3
- package/src/cli/sessions.js +11 -3
- package/src/core/commander-features.js +105 -0
- package/src/core/constants.js +1 -0
- package/src/core/dialog-manager.js +7 -0
- package/src/core/managed-navigation.js +101 -0
- package/src/core/navigation-manager.js +66 -80
- package/src/core/navigation-operation.js +25 -0
- package/src/core/navigation-readiness.js +68 -16
- package/src/core/navigation-safety.js +51 -0
- package/src/core/network-tracker.js +39 -92
- package/src/core/page-session.js +3 -2
- package/src/core/page-trigger-manager.js +130 -42
- package/src/core/readiness.js +76 -14
- package/src/core/subscriptions.js +20 -0
- package/src/core/webdriver-page.js +38 -0
- package/src/elements/locators.js +61 -37
- package/src/elements/reusable.js +92 -0
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +38 -21
- package/src/exports.js +14 -0
- package/src/factory.js +17 -16
- package/src/high-level/universal-logic.js +57 -21
- package/src/index.js +2 -0
- package/src/interactions/click.js +47 -0
- package/src/interactions/overlays.js +28 -0
- package/src/interactions/scroll.js +8 -2
- package/src/traces/bundle.js +14 -5
- package/src/traces/checkpoints.js +99 -0
- package/src/traces/links.js +117 -1
- package/src/traces/mutation-stream.js +1 -0
- package/src/traces/network.js +257 -0
- package/src/traces/page-capture.js +143 -52
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +221 -245
- package/src/traces/render.js +168 -0
- package/src/traces/rolling.js +180 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +159 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +47 -4
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/e2e/navigation-budget.e2e.test.js +132 -0
- package/tests/e2e/reusable-session.e2e.test.js +231 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
- package/tests/unit/browser/attach/snapshot.test.js +33 -0
- package/tests/unit/browser/default-browser.test.js +18 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launch-diagnostics.test.js +52 -0
- package/tests/unit/browser/launcher.test.js +5 -1
- package/tests/unit/browser/persistent-session.test.js +59 -0
- package/tests/unit/browser/quiet-ui.test.js +16 -0
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/browser/session-workflows.test.js +206 -0
- package/tests/unit/browser/storage-state.test.js +7 -2
- package/tests/unit/browser/tab-selection.test.js +38 -0
- package/tests/unit/capture/capture.test.js +52 -0
- package/tests/unit/capture/cleanup.test.js +24 -0
- package/tests/unit/capture/viewport.test.js +168 -0
- package/tests/unit/cli/dispatcher.test.js +25 -0
- package/tests/unit/core/navigation-budget.test.js +120 -0
- package/tests/unit/core/navigation-manager.test.js +23 -1
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/webdriver-page.test.js +33 -0
- package/tests/unit/elements/reusable-helpers.test.js +83 -0
- package/tests/unit/elements/visible-count.test.js +24 -0
- package/tests/unit/elements/wait-navigation.test.js +26 -0
- package/tests/unit/interactions/navigation-timeout.test.js +39 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- package/tests/unit/traces/links.test.js +2 -2
- package/tests/unit/traces/network.test.js +97 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { removeCallback, subscribeCallbacks } from './subscriptions.js';
|
|
1
2
|
/**
|
|
2
3
|
* NavigationManager - Centralized navigation handling
|
|
3
4
|
*
|
|
@@ -10,7 +11,13 @@
|
|
|
10
11
|
|
|
11
12
|
import { isNavigationError } from './navigation-safety.js';
|
|
12
13
|
import { TIMING } from './constants.js';
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
READINESS_STATUS,
|
|
16
|
+
createDeadline,
|
|
17
|
+
runWithinDeadline,
|
|
18
|
+
} from './readiness.js';
|
|
19
|
+
import { createManagedNavigator } from './managed-navigation.js';
|
|
20
|
+
import { navigationPhase as runNavigationPhase } from './navigation-operation.js';
|
|
14
21
|
import { createReadinessWaiter } from './navigation-readiness.js';
|
|
15
22
|
|
|
16
23
|
async function setManagedContent(options = {}) {
|
|
@@ -23,7 +30,7 @@ async function setManagedContent(options = {}) {
|
|
|
23
30
|
currentUrl,
|
|
24
31
|
triggerNavigationStart,
|
|
25
32
|
updateCurrentUrl,
|
|
26
|
-
|
|
33
|
+
waitForReady,
|
|
27
34
|
abandonNavigation,
|
|
28
35
|
} = options;
|
|
29
36
|
|
|
@@ -32,22 +39,35 @@ async function setManagedContent(options = {}) {
|
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
log.debug(() => '🚀 Loading in-memory HTML content');
|
|
42
|
+
const deadline = createDeadline({ timeout, signal: options.signal });
|
|
35
43
|
|
|
36
44
|
try {
|
|
37
45
|
// Treat document replacement as a controlled navigation so active page
|
|
38
46
|
// triggers stop before the old document is discarded.
|
|
39
|
-
await
|
|
40
|
-
|
|
47
|
+
await runNavigationPhase(deadline, () =>
|
|
48
|
+
triggerNavigationStart({ url: currentUrl, isExternal: false })
|
|
49
|
+
);
|
|
50
|
+
await runNavigationPhase(deadline, () =>
|
|
51
|
+
page.setContent(html, {
|
|
52
|
+
waitUntil,
|
|
53
|
+
timeout: Math.max(1, deadline.remainingMs()),
|
|
54
|
+
})
|
|
55
|
+
);
|
|
41
56
|
|
|
42
57
|
// setContent normally preserves the URL, but inline scripts can navigate.
|
|
43
58
|
updateCurrentUrl();
|
|
44
|
-
|
|
45
|
-
|
|
59
|
+
return (
|
|
60
|
+
await waitForReady({
|
|
61
|
+
deadline,
|
|
62
|
+
reason: 'after setContent',
|
|
63
|
+
checks: options.checks,
|
|
64
|
+
})
|
|
65
|
+
).ready;
|
|
46
66
|
} catch (error) {
|
|
47
67
|
// Do not leave the manager in its loading state when setContent fails, but
|
|
48
68
|
// do not claim the page became ready either - it did not.
|
|
49
69
|
abandonNavigation('setContent failed');
|
|
50
|
-
if (isNavigationError(error)) {
|
|
70
|
+
if (error.status || isNavigationError(error)) {
|
|
51
71
|
log.debug(() => '⚠️ Content loading was interrupted, recovering...');
|
|
52
72
|
return false;
|
|
53
73
|
}
|
|
@@ -77,6 +97,7 @@ export function createNavigationManager(options = {}) {
|
|
|
77
97
|
let navigationStartTime = null;
|
|
78
98
|
let navigationPromise = null;
|
|
79
99
|
let navigationResolve = null;
|
|
100
|
+
const state = { activeOperation: null, lastOutcome: null };
|
|
80
101
|
|
|
81
102
|
// Session tracking
|
|
82
103
|
let sessionId = 0;
|
|
@@ -182,6 +203,10 @@ export function createNavigationManager(options = {}) {
|
|
|
182
203
|
}
|
|
183
204
|
sessionCleanupCallbacks = [];
|
|
184
205
|
|
|
206
|
+
if (details.signal?.aborted) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
185
210
|
// Start new session
|
|
186
211
|
sessionId++;
|
|
187
212
|
isNavigating = true;
|
|
@@ -210,12 +235,14 @@ export function createNavigationManager(options = {}) {
|
|
|
210
235
|
|
|
211
236
|
// If external navigation, wait for it to complete
|
|
212
237
|
if (isExternal) {
|
|
213
|
-
await waitForPageReady({
|
|
238
|
+
await waitForPageReady({
|
|
239
|
+
reason: 'external navigation',
|
|
240
|
+
timeout: config.networkIdleTimeout,
|
|
241
|
+
});
|
|
214
242
|
}
|
|
215
243
|
}
|
|
216
244
|
|
|
217
245
|
// Track if a readiness wait is currently running to prevent concurrent calls
|
|
218
|
-
let pageReadyPromise = null;
|
|
219
246
|
|
|
220
247
|
const { waitForReady } = createReadinessWaiter({
|
|
221
248
|
page,
|
|
@@ -230,9 +257,11 @@ export function createNavigationManager(options = {}) {
|
|
|
230
257
|
log.debug(() => `🔄 Redirect detected: ${url}`);
|
|
231
258
|
}
|
|
232
259
|
},
|
|
233
|
-
onReady: () => {
|
|
260
|
+
onReady: (readyOn) => {
|
|
234
261
|
finishNavigationTracking({ ready: true });
|
|
235
|
-
|
|
262
|
+
if (readyOn === 'networkidle') {
|
|
263
|
+
emitPageReady();
|
|
264
|
+
}
|
|
236
265
|
},
|
|
237
266
|
onNotReady: () => finishNavigationTracking({ ready: false }),
|
|
238
267
|
});
|
|
@@ -250,22 +279,9 @@ export function createNavigationManager(options = {}) {
|
|
|
250
279
|
async function waitForPageReady(opts = {}) {
|
|
251
280
|
const { reason = 'page ready' } = opts;
|
|
252
281
|
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
log.debug(
|
|
257
|
-
() => `⏳ Waiting for existing page ready operation (${reason})...`
|
|
258
|
-
);
|
|
259
|
-
return pageReadyPromise;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
pageReadyPromise = waitForReady(opts).then((result) => result.ready);
|
|
263
|
-
|
|
264
|
-
try {
|
|
265
|
-
return await pageReadyPromise;
|
|
266
|
-
} finally {
|
|
267
|
-
pageReadyPromise = null;
|
|
268
|
-
}
|
|
282
|
+
// Independent policies and deadlines cannot safely share a readiness wait.
|
|
283
|
+
log.debug(() => `Waiting for page ready (${reason})`);
|
|
284
|
+
return (await waitForReady(opts)).ready;
|
|
269
285
|
}
|
|
270
286
|
|
|
271
287
|
/**
|
|
@@ -343,36 +359,15 @@ export function createNavigationManager(options = {}) {
|
|
|
343
359
|
* @param {number} options.timeout - Navigation timeout
|
|
344
360
|
* @returns {Promise<boolean>} - True if navigation succeeded
|
|
345
361
|
*/
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
try {
|
|
356
|
-
// Trigger navigation start
|
|
357
|
-
await triggerNavigationStart({ url, isExternal: false });
|
|
358
|
-
|
|
359
|
-
// Perform navigation
|
|
360
|
-
await page.goto(url, { waitUntil, timeout });
|
|
361
|
-
|
|
362
|
-
// Update current URL
|
|
363
|
-
currentUrl = page.url();
|
|
364
|
-
|
|
365
|
-
// Wait for page to be fully ready
|
|
366
|
-
return await waitForPageReady({ timeout, reason: 'after goto' });
|
|
367
|
-
} catch (error) {
|
|
368
|
-
if (isNavigationError(error)) {
|
|
369
|
-
log.debug(() => '⚠️ Navigation was interrupted, recovering...');
|
|
370
|
-
abandonNavigation('navigation interrupted');
|
|
371
|
-
return false;
|
|
372
|
-
}
|
|
373
|
-
throw error;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
362
|
+
const navigate = createManagedNavigator({
|
|
363
|
+
page,
|
|
364
|
+
state,
|
|
365
|
+
config,
|
|
366
|
+
waitForReady,
|
|
367
|
+
triggerNavigationStart,
|
|
368
|
+
updateCurrentUrl: () => (currentUrl = page.url()),
|
|
369
|
+
abandonNavigation,
|
|
370
|
+
});
|
|
376
371
|
|
|
377
372
|
const setContent = (opts = {}) =>
|
|
378
373
|
setManagedContent({
|
|
@@ -382,7 +377,7 @@ export function createNavigationManager(options = {}) {
|
|
|
382
377
|
currentUrl,
|
|
383
378
|
triggerNavigationStart,
|
|
384
379
|
updateCurrentUrl: () => (currentUrl = page.url()),
|
|
385
|
-
|
|
380
|
+
waitForReady,
|
|
386
381
|
abandonNavigation,
|
|
387
382
|
});
|
|
388
383
|
|
|
@@ -399,23 +394,15 @@ export function createNavigationManager(options = {}) {
|
|
|
399
394
|
return true; // Already ready
|
|
400
395
|
}
|
|
401
396
|
|
|
402
|
-
// Create a promise that resolves when navigation completes
|
|
403
397
|
if (!navigationPromise) {
|
|
404
398
|
navigationPromise = new Promise((resolve) => {
|
|
405
399
|
navigationResolve = resolve;
|
|
406
|
-
|
|
407
|
-
// Timeout handler
|
|
408
|
-
setTimeout(() => {
|
|
409
|
-
if (isNavigating) {
|
|
410
|
-
log.debug(() => '⚠️ waitForNavigation timeout');
|
|
411
|
-
abandonNavigation('waitForNavigation timeout');
|
|
412
|
-
resolve(false);
|
|
413
|
-
}
|
|
414
|
-
}, timeout);
|
|
415
400
|
});
|
|
416
401
|
}
|
|
417
|
-
|
|
418
|
-
|
|
402
|
+
const deadline =
|
|
403
|
+
opts.deadline ?? createDeadline({ timeout, signal: opts.signal });
|
|
404
|
+
const outcome = await runWithinDeadline(deadline, () => navigationPromise);
|
|
405
|
+
return !outcome.timedOut && !outcome.interrupted && outcome.value;
|
|
419
406
|
}
|
|
420
407
|
|
|
421
408
|
/**
|
|
@@ -450,28 +437,21 @@ export function createNavigationManager(options = {}) {
|
|
|
450
437
|
* Will be called before next navigation
|
|
451
438
|
*/
|
|
452
439
|
function onSessionCleanup(callback) {
|
|
453
|
-
sessionCleanupCallbacks
|
|
440
|
+
return subscribeCallbacks(sessionCleanupCallbacks, callback);
|
|
454
441
|
}
|
|
455
442
|
|
|
456
443
|
/**
|
|
457
444
|
* Add event listener
|
|
458
445
|
*/
|
|
459
446
|
function on(event, callback) {
|
|
460
|
-
|
|
461
|
-
listeners[event].push(callback);
|
|
462
|
-
}
|
|
447
|
+
return subscribeCallbacks(listeners[event], callback);
|
|
463
448
|
}
|
|
464
449
|
|
|
465
450
|
/**
|
|
466
451
|
* Remove event listener
|
|
467
452
|
*/
|
|
468
453
|
function off(event, callback) {
|
|
469
|
-
|
|
470
|
-
const index = listeners[event].indexOf(callback);
|
|
471
|
-
if (index !== -1) {
|
|
472
|
-
listeners[event].splice(index, 1);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
454
|
+
removeCallback(listeners[event], callback);
|
|
475
455
|
}
|
|
476
456
|
|
|
477
457
|
/**
|
|
@@ -486,6 +466,7 @@ export function createNavigationManager(options = {}) {
|
|
|
486
466
|
* Stop listening for navigation events
|
|
487
467
|
*/
|
|
488
468
|
function stopListening() {
|
|
469
|
+
state.activeOperation?.controller.abort();
|
|
489
470
|
page.off('framenavigated', handleFrameNavigation);
|
|
490
471
|
log.debug(() => '🔌 Navigation manager stopped');
|
|
491
472
|
}
|
|
@@ -500,6 +481,10 @@ export function createNavigationManager(options = {}) {
|
|
|
500
481
|
return {
|
|
501
482
|
// Navigation
|
|
502
483
|
navigate,
|
|
484
|
+
cancelNavigation: () => {
|
|
485
|
+
state.activeOperation?.controller.abort();
|
|
486
|
+
abandonNavigation('caller stopped navigation');
|
|
487
|
+
},
|
|
503
488
|
setContent,
|
|
504
489
|
waitForNavigation,
|
|
505
490
|
waitForPageReady,
|
|
@@ -507,6 +492,7 @@ export function createNavigationManager(options = {}) {
|
|
|
507
492
|
|
|
508
493
|
// State
|
|
509
494
|
isNavigating: () => isNavigating,
|
|
495
|
+
getLastOutcome: () => state.lastOutcome,
|
|
510
496
|
getCurrentUrl: () => currentUrl,
|
|
511
497
|
getSessionId: () => sessionId,
|
|
512
498
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { runWithinDeadline } from './readiness.js';
|
|
2
|
+
|
|
3
|
+
/** Stable outcome for cancellation or an exhausted operation budget. */
|
|
4
|
+
export class NavigationStoppedError extends Error {
|
|
5
|
+
constructor(status) {
|
|
6
|
+
super(
|
|
7
|
+
status === 'interrupted'
|
|
8
|
+
? 'Navigation interrupted'
|
|
9
|
+
: 'Navigation timed out'
|
|
10
|
+
);
|
|
11
|
+
this.name = 'NavigationStoppedError';
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Bound every navigation phase by the same deadline and caller signal. */
|
|
17
|
+
export async function navigationPhase(deadline, run) {
|
|
18
|
+
const result = await runWithinDeadline(deadline, run);
|
|
19
|
+
if (result.interrupted || result.timedOut) {
|
|
20
|
+
throw new NavigationStoppedError(
|
|
21
|
+
result.interrupted ? 'interrupted' : 'timed_out'
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return result.value;
|
|
25
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
networkIdleFor,
|
|
13
13
|
runReadinessChecks,
|
|
14
14
|
urlStableFor,
|
|
15
|
+
stableCheck,
|
|
15
16
|
} from './readiness.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -97,27 +98,51 @@ export function createReadinessWaiter(options = {}) {
|
|
|
97
98
|
|
|
98
99
|
log.debug(() => `⏳ Waiting for page ready (${reason})...`);
|
|
99
100
|
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const parentSignal = opts.deadline?.signal ?? opts.signal;
|
|
103
|
+
const signal = parentSignal
|
|
104
|
+
? AbortSignal.any([parentSignal, controller.signal])
|
|
105
|
+
: controller.signal;
|
|
106
|
+
const deadline = {
|
|
107
|
+
...(opts.deadline ?? createDeadline({ timeout })),
|
|
108
|
+
signal,
|
|
109
|
+
};
|
|
110
|
+
const notify = () =>
|
|
111
|
+
!opts.observeOnly &&
|
|
112
|
+
!parentSignal?.aborted &&
|
|
113
|
+
(opts.shouldNotify?.() ?? true);
|
|
114
|
+
let result;
|
|
115
|
+
try {
|
|
116
|
+
result = await runReadinessChecks({
|
|
117
|
+
checks: checks ?? checksForReadiness(opts.readyOn, config),
|
|
118
|
+
deadline,
|
|
119
|
+
context: {
|
|
120
|
+
page,
|
|
121
|
+
engine,
|
|
122
|
+
log,
|
|
123
|
+
networkTracker,
|
|
124
|
+
getAdapter,
|
|
125
|
+
onUrlSample: (url) => {
|
|
126
|
+
if (notify()) {
|
|
127
|
+
onUrlSample?.(url);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
} finally {
|
|
133
|
+
controller.abort();
|
|
134
|
+
}
|
|
114
135
|
const outcome = { ...result, reason, ...getState() };
|
|
115
136
|
|
|
116
137
|
if (outcome.ready) {
|
|
117
|
-
|
|
138
|
+
if (notify()) {
|
|
139
|
+
onReady(opts.readyOn ?? 'networkidle');
|
|
140
|
+
}
|
|
118
141
|
log.debug(() => `✅ Page ready after ${outcome.elapsedMs}ms (${reason})`);
|
|
119
142
|
} else {
|
|
120
|
-
|
|
143
|
+
if (notify()) {
|
|
144
|
+
onNotReady();
|
|
145
|
+
}
|
|
121
146
|
log.debug(
|
|
122
147
|
() =>
|
|
123
148
|
`⚠️ Page not ready (${reason}): ${outcome.status} after ` +
|
|
@@ -131,3 +156,30 @@ export function createReadinessWaiter(options = {}) {
|
|
|
131
156
|
|
|
132
157
|
return { waitForReady, getAdapter };
|
|
133
158
|
}
|
|
159
|
+
|
|
160
|
+
export function checksForReadiness(readyOn = 'networkidle', config) {
|
|
161
|
+
if (readyOn === 'networkidle') {
|
|
162
|
+
return defaultReadinessChecks(config);
|
|
163
|
+
}
|
|
164
|
+
if (readyOn === 'urlchange') {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
if (!['domcontentloaded', 'load'].includes(readyOn)) {
|
|
168
|
+
throw new RangeError(`Unknown readyOn: ${readyOn}`);
|
|
169
|
+
}
|
|
170
|
+
return [
|
|
171
|
+
stableCheck({
|
|
172
|
+
name: readyOn,
|
|
173
|
+
stableForMs: 0,
|
|
174
|
+
intervalMs: 50,
|
|
175
|
+
sample: async ({ page }) => {
|
|
176
|
+
const state = await page.evaluate(() => document.readyState);
|
|
177
|
+
return {
|
|
178
|
+
stable:
|
|
179
|
+
readyOn === 'load' ? state === 'complete' : state !== 'loading',
|
|
180
|
+
detail: { state },
|
|
181
|
+
};
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
];
|
|
185
|
+
}
|
|
@@ -8,7 +8,58 @@
|
|
|
8
8
|
* @param {Error} error - The error to check
|
|
9
9
|
* @returns {boolean} - True if this is a navigation error
|
|
10
10
|
*/
|
|
11
|
+
export class NavigationInterruptedError extends Error {
|
|
12
|
+
constructor(cause) {
|
|
13
|
+
super('Navigation interrupted the element wait', { cause });
|
|
14
|
+
this.name = 'NavigationInterruptedError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function waitNavigationState(page, manager) {
|
|
19
|
+
return { url: page.url?.(), session: manager?.getSessionId?.() };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function classifyWaitError(error, page, manager, initial) {
|
|
23
|
+
if (!isTimeoutError(error)) {
|
|
24
|
+
return error;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const current = waitNavigationState(page, manager);
|
|
28
|
+
if (current.url !== initial.url || current.session !== initial.session) {
|
|
29
|
+
return new NavigationInterruptedError(error);
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Keep the original timeout when the page identity is unavailable.
|
|
33
|
+
}
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function recoverWaitError(
|
|
38
|
+
caught,
|
|
39
|
+
options,
|
|
40
|
+
initial,
|
|
41
|
+
operation,
|
|
42
|
+
fallback
|
|
43
|
+
) {
|
|
44
|
+
const error = classifyWaitError(
|
|
45
|
+
caught,
|
|
46
|
+
options.page,
|
|
47
|
+
options.navigationManager,
|
|
48
|
+
initial
|
|
49
|
+
);
|
|
50
|
+
if (!isNavigationError(error) || options.throwOnNavigation !== false) {
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
console.log(
|
|
54
|
+
`⚠️ Navigation detected during ${operation}, recovering gracefully`
|
|
55
|
+
);
|
|
56
|
+
return fallback;
|
|
57
|
+
}
|
|
58
|
+
|
|
11
59
|
export function isNavigationError(error) {
|
|
60
|
+
if (error instanceof NavigationInterruptedError) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
12
63
|
if (!error || !error.message) {
|
|
13
64
|
return false;
|
|
14
65
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { removeCallback, subscribeCallbacks } from './subscriptions.js';
|
|
1
2
|
/**
|
|
2
3
|
* NetworkTracker - Track all HTTP requests and wait for network idle
|
|
3
4
|
*
|
|
@@ -206,101 +207,54 @@ export function createNetworkTracker(options = {}) {
|
|
|
206
207
|
* @param {number} options.idleTime - Time network must be idle (default: idleTimeout)
|
|
207
208
|
* @returns {Promise<boolean>} - True if idle, false if timeout
|
|
208
209
|
*/
|
|
209
|
-
|
|
210
|
-
const { timeout = 30000, idleTime = idleTimeout } = opts;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
// If already idle, wait for idle time
|
|
215
|
-
if (pendingRequests.size === 0) {
|
|
216
|
-
await new Promise((r) => setTimeout(r, idleTime));
|
|
217
|
-
|
|
218
|
-
// Check if still idle and no navigation happened
|
|
219
|
-
if (pendingRequests.size === 0 && navigationId === currentNavId) {
|
|
220
|
-
return true;
|
|
221
|
-
}
|
|
210
|
+
function waitForNetworkIdle(opts = {}) {
|
|
211
|
+
const { timeout = 30000, idleTime = idleTimeout, signal } = opts;
|
|
212
|
+
if (signal?.aborted || timeout <= 0) {
|
|
213
|
+
return false;
|
|
222
214
|
}
|
|
223
|
-
|
|
215
|
+
const currentNavId = navigationId;
|
|
216
|
+
const started = performance.now();
|
|
217
|
+
let quietSince = pendingRequests.size === 0 ? started : null;
|
|
224
218
|
return new Promise((resolve) => {
|
|
225
|
-
let
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (checkTimer) {
|
|
231
|
-
clearInterval(checkTimer);
|
|
232
|
-
}
|
|
233
|
-
if (timeoutTimer) {
|
|
234
|
-
clearTimeout(timeoutTimer);
|
|
235
|
-
}
|
|
236
|
-
resolved = true;
|
|
219
|
+
let timer;
|
|
220
|
+
const finish = (value) => {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
signal?.removeEventListener('abort', onAbort);
|
|
223
|
+
resolve(value);
|
|
237
224
|
};
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
);
|
|
247
|
-
|
|
248
|
-
// Log stuck requests
|
|
249
|
-
if (pendingRequests.size > 0) {
|
|
250
|
-
const stuckRequests = [];
|
|
251
|
-
for (const [key, req] of pendingRequests) {
|
|
252
|
-
const startTime = requestStartTimes.get(key);
|
|
253
|
-
const duration = Date.now() - startTime;
|
|
254
|
-
const url = typeof req.url === 'function' ? req.url() : req.url;
|
|
255
|
-
stuckRequests.push(
|
|
256
|
-
` ${url.substring(0, 60)}... (${duration}ms)`
|
|
257
|
-
);
|
|
258
|
-
}
|
|
259
|
-
log.debug(() => `⚠️ Stuck requests:\n${stuckRequests.join('\n')}`);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
resolve(false);
|
|
263
|
-
}
|
|
264
|
-
}, timeout);
|
|
265
|
-
|
|
266
|
-
// Check periodically for idle state
|
|
267
|
-
checkTimer = setInterval(async () => {
|
|
268
|
-
if (resolved) {
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Check for navigation change (abort wait)
|
|
273
|
-
if (navigationId !== currentNavId) {
|
|
274
|
-
cleanup();
|
|
275
|
-
resolve(false);
|
|
225
|
+
const onAbort = () => finish(false);
|
|
226
|
+
const sample = () => {
|
|
227
|
+
const now = performance.now();
|
|
228
|
+
if (
|
|
229
|
+
signal?.aborted ||
|
|
230
|
+
navigationId !== currentNavId ||
|
|
231
|
+
now - started >= timeout
|
|
232
|
+
) {
|
|
233
|
+
finish(false);
|
|
276
234
|
return;
|
|
277
235
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const now = Date.now();
|
|
281
|
-
for (const [key, startTime] of requestStartTimes) {
|
|
282
|
-
if (now - startTime > requestTimeout) {
|
|
283
|
-
log.debug(() => `⚠️ Request timed out, removing: ${key}`);
|
|
236
|
+
for (const [key, requestStart] of requestStartTimes) {
|
|
237
|
+
if (Date.now() - requestStart > requestTimeout) {
|
|
284
238
|
pendingRequests.delete(key);
|
|
285
239
|
requestStartTimes.delete(key);
|
|
286
240
|
}
|
|
287
241
|
}
|
|
288
|
-
|
|
289
|
-
// Check if idle
|
|
290
242
|
if (pendingRequests.size === 0) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
!resolved &&
|
|
296
|
-
pendingRequests.size === 0 &&
|
|
297
|
-
navigationId === currentNavId
|
|
298
|
-
) {
|
|
299
|
-
cleanup();
|
|
300
|
-
resolve(true);
|
|
243
|
+
quietSince ??= now;
|
|
244
|
+
if (now - quietSince >= idleTime) {
|
|
245
|
+
finish(true);
|
|
246
|
+
return;
|
|
301
247
|
}
|
|
248
|
+
} else {
|
|
249
|
+
quietSince = null;
|
|
302
250
|
}
|
|
303
|
-
|
|
251
|
+
timer = setTimeout(
|
|
252
|
+
sample,
|
|
253
|
+
Math.max(1, Math.min(25, timeout - (now - started)))
|
|
254
|
+
);
|
|
255
|
+
};
|
|
256
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
257
|
+
sample();
|
|
304
258
|
});
|
|
305
259
|
}
|
|
306
260
|
|
|
@@ -327,21 +281,14 @@ export function createNetworkTracker(options = {}) {
|
|
|
327
281
|
* Add event listener
|
|
328
282
|
*/
|
|
329
283
|
function on(event, callback) {
|
|
330
|
-
|
|
331
|
-
listeners[event].push(callback);
|
|
332
|
-
}
|
|
284
|
+
return subscribeCallbacks(listeners[event], callback);
|
|
333
285
|
}
|
|
334
286
|
|
|
335
287
|
/**
|
|
336
288
|
* Remove event listener
|
|
337
289
|
*/
|
|
338
290
|
function off(event, callback) {
|
|
339
|
-
|
|
340
|
-
const index = listeners[event].indexOf(callback);
|
|
341
|
-
if (index !== -1) {
|
|
342
|
-
listeners[event].splice(index, 1);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
291
|
+
removeCallback(listeners[event], callback);
|
|
345
292
|
}
|
|
346
293
|
|
|
347
294
|
/**
|
package/src/core/page-session.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { subscribeCallbacks } from './subscriptions.js';
|
|
1
2
|
/**
|
|
2
3
|
* PageSession - Abstraction for page lifecycle management
|
|
3
4
|
*
|
|
@@ -151,9 +152,9 @@ export function createPageSessionFactory(options = {}) {
|
|
|
151
152
|
log.debug(
|
|
152
153
|
() => `⚠️ Cannot register cleanup on inactive session "${name}"`
|
|
153
154
|
);
|
|
154
|
-
return;
|
|
155
|
+
return () => {};
|
|
155
156
|
}
|
|
156
|
-
cleanupCallbacks
|
|
157
|
+
return subscribeCallbacks(cleanupCallbacks, callback);
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
/**
|