browser-commander 0.26.3 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/.jscpd-baseline.json +24 -23
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +6 -0
  4. package/eslint.config.js +11 -0
  5. package/package.json +9 -4
  6. package/src/bindings.js +19 -0
  7. package/src/browser/attach/snapshot-launch.js +8 -6
  8. package/src/browser/attach/snapshot.js +39 -2
  9. package/src/browser/browser-cookie-database.js +71 -2
  10. package/src/browser/browser-cookie-session.js +183 -0
  11. package/src/browser/browser-cookies.js +29 -0
  12. package/src/browser/connector.js +117 -13
  13. package/src/browser/debugging-port.js +4 -2
  14. package/src/browser/default-browser.js +33 -9
  15. package/src/browser/launch-diagnostics.js +102 -0
  16. package/src/browser/launch-options.js +2 -1
  17. package/src/browser/launch-restrictions.json +33 -2
  18. package/src/browser/launcher.js +54 -9
  19. package/src/browser/migration/sqlite-snapshot.js +5 -27
  20. package/src/browser/navigation.js +173 -98
  21. package/src/browser/persistent-session.js +360 -0
  22. package/src/browser/profile-directory.js +20 -0
  23. package/src/browser/real-browser.js +142 -70
  24. package/src/browser/restrictions.js +13 -4
  25. package/src/browser/session-cookies.js +78 -0
  26. package/src/browser/session-persistence.js +72 -0
  27. package/src/browser/session-watchdog.js +71 -0
  28. package/src/browser/session-worker.js +70 -0
  29. package/src/browser/storage-state.js +32 -6
  30. package/src/browser/webdriver.js +4 -0
  31. package/src/capture/encoding.js +327 -0
  32. package/src/capture/errors.js +12 -0
  33. package/src/capture/files.js +9 -0
  34. package/src/capture/index.js +5 -0
  35. package/src/capture/recording.js +136 -0
  36. package/src/capture/screenshot.js +222 -0
  37. package/src/capture/video.js +186 -0
  38. package/src/capture/viewport.js +68 -0
  39. package/src/capture/worker.js +22 -0
  40. package/src/cli/args.js +66 -1
  41. package/src/cli/commands.js +113 -2
  42. package/src/cli/high-level-methods.js +40 -3
  43. package/src/cli/sessions.js +11 -3
  44. package/src/core/commander-features.js +105 -0
  45. package/src/core/constants.js +1 -0
  46. package/src/core/dialog-manager.js +7 -0
  47. package/src/core/managed-navigation.js +101 -0
  48. package/src/core/navigation-manager.js +66 -80
  49. package/src/core/navigation-operation.js +25 -0
  50. package/src/core/navigation-readiness.js +68 -16
  51. package/src/core/navigation-safety.js +51 -0
  52. package/src/core/network-tracker.js +39 -92
  53. package/src/core/page-session.js +3 -2
  54. package/src/core/page-trigger-manager.js +130 -42
  55. package/src/core/readiness.js +76 -14
  56. package/src/core/subscriptions.js +20 -0
  57. package/src/core/webdriver-page.js +38 -0
  58. package/src/elements/locators.js +61 -37
  59. package/src/elements/reusable.js +92 -0
  60. package/src/elements/selectors.js +37 -81
  61. package/src/elements/visibility.js +38 -21
  62. package/src/exports.js +14 -0
  63. package/src/factory.js +17 -16
  64. package/src/high-level/universal-logic.js +57 -21
  65. package/src/index.js +2 -0
  66. package/src/interactions/click.js +47 -0
  67. package/src/interactions/overlays.js +28 -0
  68. package/src/interactions/scroll.js +8 -2
  69. package/src/traces/bundle.js +14 -5
  70. package/src/traces/checkpoints.js +99 -0
  71. package/src/traces/links.js +117 -1
  72. package/src/traces/mutation-stream.js +1 -0
  73. package/src/traces/network.js +257 -0
  74. package/src/traces/page-capture.js +143 -52
  75. package/src/traces/reader.js +8 -10
  76. package/src/traces/recorder.js +221 -245
  77. package/src/traces/render.js +168 -0
  78. package/src/traces/rolling.js +180 -0
  79. package/src/traces/start-cleanup.js +15 -0
  80. package/src/traces/stop.js +159 -0
  81. package/src/traces/storage.js +43 -0
  82. package/src/utilities/subprocess.js +47 -4
  83. package/tests/e2e/cli.e2e.test.js +20 -1
  84. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  85. package/tests/e2e/navigation-budget.e2e.test.js +132 -0
  86. package/tests/e2e/reusable-session.e2e.test.js +231 -0
  87. package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
  88. package/tests/unit/browser/attach/snapshot.test.js +33 -0
  89. package/tests/unit/browser/default-browser.test.js +18 -0
  90. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  91. package/tests/unit/browser/launch-diagnostics.test.js +52 -0
  92. package/tests/unit/browser/launcher.test.js +5 -1
  93. package/tests/unit/browser/persistent-session.test.js +59 -0
  94. package/tests/unit/browser/quiet-ui.test.js +16 -0
  95. package/tests/unit/browser/real-browser.test.js +1 -1
  96. package/tests/unit/browser/session-workflows.test.js +206 -0
  97. package/tests/unit/browser/storage-state.test.js +7 -2
  98. package/tests/unit/browser/tab-selection.test.js +38 -0
  99. package/tests/unit/capture/capture.test.js +52 -0
  100. package/tests/unit/capture/cleanup.test.js +24 -0
  101. package/tests/unit/capture/viewport.test.js +168 -0
  102. package/tests/unit/cli/dispatcher.test.js +25 -0
  103. package/tests/unit/core/navigation-budget.test.js +120 -0
  104. package/tests/unit/core/navigation-manager.test.js +23 -1
  105. package/tests/unit/core/readiness.test.js +21 -0
  106. package/tests/unit/core/trigger-readiness.test.js +39 -0
  107. package/tests/unit/core/webdriver-page.test.js +33 -0
  108. package/tests/unit/elements/reusable-helpers.test.js +83 -0
  109. package/tests/unit/elements/visible-count.test.js +24 -0
  110. package/tests/unit/elements/wait-navigation.test.js +26 -0
  111. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  112. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  113. package/tests/unit/traces/bundle.test.js +16 -0
  114. package/tests/unit/traces/links.test.js +2 -2
  115. package/tests/unit/traces/network.test.js +97 -0
@@ -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 { READINESS_STATUS } from './readiness.js';
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
- waitForPageReady,
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 triggerNavigationStart({ url: currentUrl, isExternal: false });
40
- await page.setContent(html, { waitUntil, timeout });
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
- await waitForPageReady({ timeout, reason: 'after setContent' });
45
- return true;
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({ reason: 'external navigation' });
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
- emitPageReady();
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
- // Concurrent callers join the in-flight wait instead of racing a second
254
- // deadline against the first.
255
- if (pageReadyPromise) {
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
- async function navigate(opts = {}) {
347
- const { url, waitUntil = 'domcontentloaded', timeout = 60000 } = opts;
348
-
349
- if (!url) {
350
- throw new Error('url is required in options');
351
- }
352
-
353
- log.debug(() => `🚀 Navigating to: ${url}`);
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
- waitForPageReady,
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
- return await navigationPromise;
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.push(callback);
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
- if (listeners[event]) {
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
- if (listeners[event]) {
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 deadline = createDeadline({ timeout });
101
- const result = await runReadinessChecks({
102
- checks: checks ?? defaultReadinessChecks(config),
103
- deadline,
104
- context: {
105
- page,
106
- engine,
107
- log,
108
- networkTracker,
109
- getAdapter,
110
- onUrlSample,
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
- onReady();
138
+ if (notify()) {
139
+ onReady(opts.readyOn ?? 'networkidle');
140
+ }
118
141
  log.debug(() => `✅ Page ready after ${outcome.elapsedMs}ms (${reason})`);
119
142
  } else {
120
- onNotReady();
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
- async function waitForNetworkIdle(opts = {}) {
210
- const { timeout = 30000, idleTime = idleTimeout } = opts;
211
-
212
- const currentNavId = navigationId;
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 resolved = false;
226
- let checkTimer = null;
227
- let timeoutTimer = null;
228
-
229
- const cleanup = () => {
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
- // Timeout handler
240
- timeoutTimer = setTimeout(() => {
241
- if (!resolved) {
242
- cleanup();
243
- log.debug(
244
- () =>
245
- `⚠️ Network idle timeout after ${timeout}ms (${pendingRequests.size} pending)`
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
- // Check for timed out requests and remove them
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
- // Wait for idle time to confirm
292
- await new Promise((r) => setTimeout(r, idleTime));
293
-
294
- if (
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
- }, 100);
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
- if (listeners[event]) {
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
- if (listeners[event]) {
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
  /**
@@ -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.push(callback);
157
+ return subscribeCallbacks(cleanupCallbacks, callback);
157
158
  }
158
159
 
159
160
  /**