browser-commander 0.17.2 → 0.19.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 (74) hide show
  1. package/.jscpd-baseline.json +12 -17
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +132 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +5 -1
  6. package/src/bindings.js +13 -1
  7. package/src/browser/connector.js +15 -3
  8. package/src/browser/launcher.js +14 -2
  9. package/src/browser/navigation.js +63 -0
  10. package/src/core/dialog-manager.js +48 -0
  11. package/src/core/navigation-manager.js +83 -87
  12. package/src/core/navigation-readiness.js +133 -0
  13. package/src/core/readiness.js +478 -0
  14. package/src/downloads/attach.js +58 -0
  15. package/src/downloads/destination.js +125 -0
  16. package/src/downloads/manager.js +476 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +238 -0
  19. package/src/downloads/staging.js +146 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +86 -0
  22. package/src/factory.js +55 -0
  23. package/src/interactions/click-activation.js +317 -0
  24. package/src/interactions/click-result.js +108 -0
  25. package/src/interactions/click.js +766 -352
  26. package/src/tests/index.js +75 -11
  27. package/src/tests/tracing.js +236 -0
  28. package/src/traces/bundle.js +338 -0
  29. package/src/traces/deadline.js +33 -0
  30. package/src/traces/identity.js +127 -0
  31. package/src/traces/init-script.js +95 -0
  32. package/src/traces/links.js +623 -0
  33. package/src/traces/mutation-stream.js +232 -0
  34. package/src/traces/observers.js +272 -0
  35. package/src/traces/page-capture.js +601 -0
  36. package/src/traces/reader.js +226 -0
  37. package/src/traces/recorder.js +454 -0
  38. package/src/traces/redaction.js +231 -0
  39. package/src/traces/schema.js +229 -0
  40. package/src/traces/viewer.js +448 -0
  41. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  42. package/tests/e2e/downloads.e2e.test.js +235 -0
  43. package/tests/e2e/traces.e2e.test.js +742 -0
  44. package/tests/helpers/click-fixtures.js +62 -0
  45. package/tests/helpers/download-fixtures.js +125 -0
  46. package/tests/helpers/download-server.js +129 -0
  47. package/tests/helpers/e2e-browser.js +67 -0
  48. package/tests/helpers/file-modes.js +31 -0
  49. package/tests/helpers/fixture-server.js +56 -0
  50. package/tests/helpers/mocks.js +63 -66
  51. package/tests/helpers/readiness-server.js +96 -0
  52. package/tests/helpers/temp-directory.js +44 -0
  53. package/tests/helpers/trace-fixtures.js +274 -0
  54. package/tests/helpers/trace-server.js +173 -0
  55. package/tests/unit/core/dialog-manager.test.js +118 -10
  56. package/tests/unit/core/navigation-manager.test.js +162 -0
  57. package/tests/unit/core/readiness.test.js +517 -0
  58. package/tests/unit/downloads/attach.test.js +220 -0
  59. package/tests/unit/downloads/destination.test.js +114 -0
  60. package/tests/unit/downloads/manager.test.js +485 -0
  61. package/tests/unit/downloads/naming.test.js +174 -0
  62. package/tests/unit/downloads/staging.test.js +117 -0
  63. package/tests/unit/downloads/store.test.js +215 -0
  64. package/tests/unit/interactions/click-activation.test.js +283 -0
  65. package/tests/unit/interactions/click-result.test.js +90 -0
  66. package/tests/unit/interactions/click.test.js +409 -200
  67. package/tests/unit/tests/tracing.test.js +299 -0
  68. package/tests/unit/traces/bundle.test.js +260 -0
  69. package/tests/unit/traces/links.test.js +557 -0
  70. package/tests/unit/traces/reader.test.js +208 -0
  71. package/tests/unit/traces/recorder.test.js +730 -0
  72. package/tests/unit/traces/redaction.test.js +170 -0
  73. package/tests/unit/traces/schema.test.js +92 -0
  74. package/tests/unit/traces/viewer.test.js +294 -0
@@ -28,6 +28,12 @@ export function createDialogManager(options = {}) {
28
28
  // User-registered dialog handlers
29
29
  const handlers = [];
30
30
 
31
+ // Passive observers. They are told about every dialog and are never
32
+ // responsible for answering one, so watching dialogs - which is what a
33
+ // trace recorder does - cannot leave a page waiting forever for an answer
34
+ // nobody was going to give.
35
+ const observers = [];
36
+
31
37
  // Whether we are currently listening to the page's dialog event
32
38
  let isListening = false;
33
39
 
@@ -45,6 +51,14 @@ export function createDialogManager(options = {}) {
45
51
 
46
52
  log.debug(() => `💬 Dialog event: type="${type}", message="${message}"`);
47
53
 
54
+ for (const observer of observers) {
55
+ try {
56
+ await observer(dialog);
57
+ } catch (e) {
58
+ log.debug(() => `⚠️ Error in dialog observer: ${e.message}`);
59
+ }
60
+ }
61
+
48
62
  if (handlers.length === 0) {
49
63
  log.debug(
50
64
  () =>
@@ -121,6 +135,36 @@ export function createDialogManager(options = {}) {
121
135
  log.debug(() => '🔌 All dialog handlers cleared');
122
136
  }
123
137
 
138
+ /**
139
+ * Watch dialogs without taking responsibility for answering them
140
+ * @param {Function} observer - Async function receiving the dialog object
141
+ * @returns {Function} - Removes this observer again
142
+ */
143
+ function observeDialogs(observer) {
144
+ if (typeof observer !== 'function') {
145
+ throw new Error('Dialog observer must be a function');
146
+ }
147
+ observers.push(observer);
148
+ log.debug(
149
+ () => `👁 Dialog observer registered (total: ${observers.length})`
150
+ );
151
+ return () => unobserveDialogs(observer);
152
+ }
153
+
154
+ /**
155
+ * Remove a dialog observer
156
+ * @param {Function} observer - The observer to remove
157
+ */
158
+ function unobserveDialogs(observer) {
159
+ const index = observers.indexOf(observer);
160
+ if (index !== -1) {
161
+ observers.splice(index, 1);
162
+ log.debug(
163
+ () => `👁 Dialog observer removed (remaining: ${observers.length})`
164
+ );
165
+ }
166
+ }
167
+
124
168
  /**
125
169
  * Start listening for dialog events on the page
126
170
  */
@@ -151,6 +195,10 @@ export function createDialogManager(options = {}) {
151
195
  offDialog,
152
196
  clearDialogHandlers,
153
197
 
198
+ // Passive observation (issue #87)
199
+ observeDialogs,
200
+ unobserveDialogs,
201
+
154
202
  // Lifecycle
155
203
  startListening,
156
204
  stopListening,
@@ -10,6 +10,8 @@
10
10
 
11
11
  import { isNavigationError } from './navigation-safety.js';
12
12
  import { TIMING } from './constants.js';
13
+ import { READINESS_STATUS } from './readiness.js';
14
+ import { createReadinessWaiter } from './navigation-readiness.js';
13
15
 
14
16
  async function setManagedContent(options = {}) {
15
17
  const {
@@ -22,7 +24,7 @@ async function setManagedContent(options = {}) {
22
24
  triggerNavigationStart,
23
25
  updateCurrentUrl,
24
26
  waitForPageReady,
25
- completeNavigation,
27
+ abandonNavigation,
26
28
  } = options;
27
29
 
28
30
  if (typeof html !== 'string') {
@@ -42,8 +44,9 @@ async function setManagedContent(options = {}) {
42
44
  await waitForPageReady({ timeout, reason: 'after setContent' });
43
45
  return true;
44
46
  } catch (error) {
45
- // Do not leave the manager in its loading state when setContent fails.
46
- completeNavigation();
47
+ // Do not leave the manager in its loading state when setContent fails, but
48
+ // do not claim the page became ready either - it did not.
49
+ abandonNavigation('setContent failed');
47
50
  if (isNavigationError(error)) {
48
51
  log.debug(() => '⚠️ Content loading was interrupted, recovering...');
49
52
  return false;
@@ -211,21 +214,44 @@ export function createNavigationManager(options = {}) {
211
214
  }
212
215
  }
213
216
 
214
- // Track if waitForPageReady is currently running to prevent concurrent calls
217
+ // Track if a readiness wait is currently running to prevent concurrent calls
215
218
  let pageReadyPromise = null;
216
219
 
220
+ const { waitForReady } = createReadinessWaiter({
221
+ page,
222
+ engine,
223
+ log,
224
+ networkTracker,
225
+ config,
226
+ getState: () => ({ url: currentUrl, sessionId }),
227
+ onUrlSample: (url) => {
228
+ if (url !== currentUrl) {
229
+ currentUrl = url;
230
+ log.debug(() => `🔄 Redirect detected: ${url}`);
231
+ }
232
+ },
233
+ onReady: () => {
234
+ finishNavigationTracking({ ready: true });
235
+ emitPageReady();
236
+ },
237
+ onNotReady: () => finishNavigationTracking({ ready: false }),
238
+ });
239
+
217
240
  /**
218
- * Wait for page to be ready (DOM loaded + network idle + no redirects)
219
- * @param {Object} options - Configuration options
220
- * @param {number} options.timeout - Maximum time to wait
221
- * @param {string} options.reason - Reason for waiting (for logging)
222
- * @returns {Promise<boolean>} - True if ready, false if timeout
241
+ * Boolean-returning readiness wait kept for backward compatibility.
242
+ *
243
+ * Unlike the previous implementation this returns `false` when the page did
244
+ * not actually become ready, and it never emits the page-ready event in that
245
+ * case. Prefer {@link waitForReady} for the structured result.
246
+ *
247
+ * @param {Object} [opts] - Same options as {@link waitForReady}
248
+ * @returns {Promise<boolean>} Whether the page genuinely became ready
223
249
  */
224
250
  async function waitForPageReady(opts = {}) {
225
- const { timeout = config.networkIdleTimeout, reason = 'page ready' } = opts;
251
+ const { reason = 'page ready' } = opts;
226
252
 
227
- // If another waitForPageReady is already running, wait for it instead of starting a new one
228
- // This prevents concurrent waits that can cause race conditions
253
+ // Concurrent callers join the in-flight wait instead of racing a second
254
+ // deadline against the first.
229
255
  if (pageReadyPromise) {
230
256
  log.debug(
231
257
  () => `⏳ Waiting for existing page ready operation (${reason})...`
@@ -233,74 +259,38 @@ export function createNavigationManager(options = {}) {
233
259
  return pageReadyPromise;
234
260
  }
235
261
 
236
- log.debug(() => `⏳ Waiting for page ready (${reason})...`);
237
-
238
- // Create the promise and store it
239
- pageReadyPromise = (async () => {
240
- const startTime = Date.now();
241
- let lastUrlChangeTime = Date.now();
242
-
243
- // Wait for URL to stabilize (no more redirects)
244
- while (
245
- Date.now() - lastUrlChangeTime <
246
- config.redirectStabilizationTime
247
- ) {
248
- if (Date.now() - startTime > timeout) {
249
- log.debug(
250
- () => `⚠️ Page ready timeout after ${timeout}ms (${reason})`
251
- );
252
- break;
253
- }
254
-
255
- await new Promise((r) => setTimeout(r, 200));
256
-
257
- // Check if URL changed
258
- const nowUrl = page.url();
259
- if (nowUrl !== currentUrl) {
260
- currentUrl = nowUrl;
261
- lastUrlChangeTime = Date.now();
262
- log.debug(() => `🔄 Redirect detected: ${nowUrl}`);
263
- }
264
- }
265
-
266
- // Wait for network idle - use remaining time but ensure at least 30s for idle check
267
- // The 30s idle time is enforced by the network tracker's idleTimeout config
268
- if (networkTracker) {
269
- const elapsed = Date.now() - startTime;
270
- // Give at least 60 seconds for network idle, or remaining time if more
271
- const remainingTimeout = Math.max(60000, timeout - elapsed);
272
-
273
- const networkIdle = await networkTracker.waitForNetworkIdle({
274
- timeout: remainingTimeout,
275
- // idleTime defaults to TIMING.NAVIGATION_TIMEOUT from tracker config
276
- });
277
-
278
- if (!networkIdle) {
279
- log.debug(() => `⚠️ Network did not become idle (${reason})`);
280
- }
281
- }
282
-
283
- // Complete navigation
284
- completeNavigation();
285
-
286
- const elapsed = Date.now() - startTime;
287
- log.debug(() => `✅ Page ready after ${elapsed}ms (${reason})`);
288
-
289
- return true;
290
- })();
262
+ pageReadyPromise = waitForReady(opts).then((result) => result.ready);
291
263
 
292
264
  try {
293
265
  return await pageReadyPromise;
294
266
  } finally {
295
- // Clear the promise so next call can start fresh
296
267
  pageReadyPromise = null;
297
268
  }
298
269
  }
299
270
 
300
271
  /**
301
- * Complete current navigation
272
+ * Leave the navigating state without claiming the page became ready.
273
+ *
274
+ * @param {string} reason - Why the navigation was abandoned
275
+ */
276
+ function abandonNavigation(reason) {
277
+ log.debug(() => `⚠️ Navigation abandoned: ${reason}`);
278
+ finishNavigationTracking({ ready: false });
279
+ }
280
+
281
+ /**
282
+ * Close out navigation bookkeeping.
283
+ *
284
+ * This only reports that the navigation stopped being in flight. Whether the
285
+ * page is usable is a separate question answered by {@link emitPageReady},
286
+ * which is why the two are no longer one function.
287
+ *
288
+ * @param {Object} [options] - Configuration options
289
+ * @param {boolean} [options.ready=false] - Whether the page reached a ready state
302
290
  */
303
- function completeNavigation() {
291
+ function finishNavigationTracking(options = {}) {
292
+ const { ready = false } = options;
293
+
304
294
  if (!isNavigating) {
305
295
  return;
306
296
  }
@@ -310,13 +300,14 @@ export function createNavigationManager(options = {}) {
310
300
  navigationStartTime = null;
311
301
 
312
302
  log.debug(
313
- () => `✅ Navigation complete (session ${sessionId}, ${duration}ms)`
303
+ () =>
304
+ `${ready ? '✅' : '⚠️ '} Navigation finished (session ${sessionId}, ` +
305
+ `${duration}ms, ready=${ready})`
314
306
  );
315
307
 
316
- // Notify navigation complete listeners
317
308
  listeners.onNavigationComplete.forEach((fn) => {
318
309
  try {
319
- fn({ url: currentUrl, sessionId, duration });
310
+ fn({ url: currentUrl, sessionId, duration, ready });
320
311
  } catch (e) {
321
312
  log.debug(
322
313
  () => `⚠️ Error in onNavigationComplete listener: ${e.message}`
@@ -324,7 +315,17 @@ export function createNavigationManager(options = {}) {
324
315
  }
325
316
  });
326
317
 
327
- // Notify page ready listeners
318
+ if (navigationResolve) {
319
+ navigationResolve(ready);
320
+ navigationResolve = null;
321
+ navigationPromise = null;
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Announce that the page reached a ready state.
327
+ */
328
+ function emitPageReady() {
328
329
  listeners.onPageReady.forEach((fn) => {
329
330
  try {
330
331
  fn({ url: currentUrl, sessionId });
@@ -332,13 +333,6 @@ export function createNavigationManager(options = {}) {
332
333
  log.debug(() => `⚠️ Error in onPageReady listener: ${e.message}`);
333
334
  }
334
335
  });
335
-
336
- // Resolve navigation promise if waiting
337
- if (navigationResolve) {
338
- navigationResolve(true);
339
- navigationResolve = null;
340
- navigationPromise = null;
341
- }
342
336
  }
343
337
 
344
338
  /**
@@ -369,13 +363,11 @@ export function createNavigationManager(options = {}) {
369
363
  currentUrl = page.url();
370
364
 
371
365
  // Wait for page to be fully ready
372
- await waitForPageReady({ timeout, reason: 'after goto' });
373
-
374
- return true;
366
+ return await waitForPageReady({ timeout, reason: 'after goto' });
375
367
  } catch (error) {
376
368
  if (isNavigationError(error)) {
377
369
  log.debug(() => '⚠️ Navigation was interrupted, recovering...');
378
- completeNavigation();
370
+ abandonNavigation('navigation interrupted');
379
371
  return false;
380
372
  }
381
373
  throw error;
@@ -391,7 +383,7 @@ export function createNavigationManager(options = {}) {
391
383
  triggerNavigationStart,
392
384
  updateCurrentUrl: () => (currentUrl = page.url()),
393
385
  waitForPageReady,
394
- completeNavigation,
386
+ abandonNavigation,
395
387
  });
396
388
 
397
389
  /**
@@ -416,7 +408,7 @@ export function createNavigationManager(options = {}) {
416
408
  setTimeout(() => {
417
409
  if (isNavigating) {
418
410
  log.debug(() => '⚠️ waitForNavigation timeout');
419
- completeNavigation();
411
+ abandonNavigation('waitForNavigation timeout');
420
412
  resolve(false);
421
413
  }
422
414
  }, timeout);
@@ -511,6 +503,7 @@ export function createNavigationManager(options = {}) {
511
503
  setContent,
512
504
  waitForNavigation,
513
505
  waitForPageReady,
506
+ waitForReady,
514
507
 
515
508
  // State
516
509
  isNavigating: () => isNavigating,
@@ -532,5 +525,8 @@ export function createNavigationManager(options = {}) {
532
525
  startListening,
533
526
  stopListening,
534
527
  configure,
528
+
529
+ // Readiness status vocabulary, re-exported for callers matching on it
530
+ READINESS_STATUS,
535
531
  };
536
532
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Readiness wiring for the navigation manager.
3
+ *
4
+ * Kept separate from `navigation-manager.js` so the two halves of "the page
5
+ * stopped navigating" and "the page is actually usable" stay visibly distinct:
6
+ * this module decides only the second question and reports the evidence behind
7
+ * its answer.
8
+ */
9
+
10
+ import {
11
+ createDeadline,
12
+ networkIdleFor,
13
+ runReadinessChecks,
14
+ urlStableFor,
15
+ } from './readiness.js';
16
+
17
+ /**
18
+ * Build the checks that define "ready" when a caller does not supply their own.
19
+ *
20
+ * @param {Object} config - Navigation manager configuration
21
+ * @param {number} config.redirectStabilizationTime - Required URL quiet period
22
+ * @returns {Array} Ordered readiness checks
23
+ */
24
+ export function defaultReadinessChecks(config) {
25
+ return [
26
+ urlStableFor({
27
+ stableForMs: config.redirectStabilizationTime,
28
+ intervalMs: 200,
29
+ }),
30
+ networkIdleFor(),
31
+ ];
32
+ }
33
+
34
+ /**
35
+ * Create the readiness waiter used by the navigation manager.
36
+ *
37
+ * @param {Object} options - Configuration options
38
+ * @param {Object} options.page - Browser page object
39
+ * @param {string} options.engine - Engine name
40
+ * @param {Function} options.log - Logger instance
41
+ * @param {Object} [options.networkTracker] - NetworkTracker instance
42
+ * @param {Object} options.config - Navigation manager configuration
43
+ * @param {Function} options.getState - Returns `{currentUrl, sessionId}`
44
+ * @param {Function} options.onUrlSample - Called with every sampled URL
45
+ * @param {Function} options.onReady - Called when every check passed
46
+ * @param {Function} options.onNotReady - Called when the wait did not succeed
47
+ * @returns {{waitForReady: Function, getAdapter: Function}} Readiness API
48
+ */
49
+ export function createReadinessWaiter(options = {}) {
50
+ const {
51
+ page,
52
+ engine,
53
+ log,
54
+ networkTracker,
55
+ config,
56
+ getState,
57
+ onUrlSample,
58
+ onReady,
59
+ onNotReady,
60
+ } = options;
61
+
62
+ let cachedAdapter;
63
+
64
+ /**
65
+ * Lazily build an engine adapter for checks that need to evaluate in-page.
66
+ * Kept lazy so the default checks never touch a page that cannot evaluate.
67
+ *
68
+ * @returns {Promise<Object>} Engine adapter
69
+ */
70
+ async function getAdapter() {
71
+ if (cachedAdapter === undefined) {
72
+ const { createEngineAdapter } = await import('./engine-adapter.js');
73
+ cachedAdapter = createEngineAdapter(page, engine);
74
+ }
75
+ return cachedAdapter;
76
+ }
77
+
78
+ /**
79
+ * Wait for the page to be ready and report exactly what was observed.
80
+ *
81
+ * Every check shares one monotonic deadline, so the total wait can never
82
+ * exceed `timeout` no matter how many checks run or how slow each one is.
83
+ * The page-ready event fires only when every check actually passed.
84
+ *
85
+ * @param {Object} [opts] - Configuration options
86
+ * @param {number} [opts.timeout] - Total budget in milliseconds
87
+ * @param {string} [opts.reason] - Reason for waiting (for logging)
88
+ * @param {Array} [opts.checks] - Composable readiness checks to run
89
+ * @returns {Promise<Object>} Structured readiness result
90
+ */
91
+ async function waitForReady(opts = {}) {
92
+ const {
93
+ timeout = config.networkIdleTimeout,
94
+ reason = 'page ready',
95
+ checks,
96
+ } = opts;
97
+
98
+ log.debug(() => `⏳ Waiting for page ready (${reason})...`);
99
+
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
+
114
+ const outcome = { ...result, reason, ...getState() };
115
+
116
+ if (outcome.ready) {
117
+ onReady();
118
+ log.debug(() => `✅ Page ready after ${outcome.elapsedMs}ms (${reason})`);
119
+ } else {
120
+ onNotReady();
121
+ log.debug(
122
+ () =>
123
+ `⚠️ Page not ready (${reason}): ${outcome.status} after ` +
124
+ `${outcome.elapsedMs}ms; failed=[${outcome.checks.failed.join(', ')}] ` +
125
+ `pending=[${outcome.checks.pending.join(', ')}]`
126
+ );
127
+ }
128
+
129
+ return outcome;
130
+ }
131
+
132
+ return { waitForReady, getAdapter };
133
+ }