browser-commander 0.17.2 → 0.18.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 (66) hide show
  1. package/.jscpd-baseline.json +4 -17
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +84 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +4 -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 +453 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +188 -0
  19. package/src/downloads/store.js +256 -0
  20. package/src/exports.js +72 -0
  21. package/src/factory.js +55 -0
  22. package/src/interactions/click-activation.js +317 -0
  23. package/src/interactions/click-result.js +108 -0
  24. package/src/interactions/click.js +766 -352
  25. package/src/tests/index.js +75 -11
  26. package/src/tests/tracing.js +236 -0
  27. package/src/traces/bundle.js +323 -0
  28. package/src/traces/observers.js +246 -0
  29. package/src/traces/page-capture.js +365 -0
  30. package/src/traces/reader.js +226 -0
  31. package/src/traces/recorder.js +437 -0
  32. package/src/traces/redaction.js +231 -0
  33. package/src/traces/schema.js +165 -0
  34. package/src/traces/viewer.js +302 -0
  35. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  36. package/tests/e2e/downloads.e2e.test.js +188 -0
  37. package/tests/e2e/traces.e2e.test.js +457 -0
  38. package/tests/helpers/click-fixtures.js +62 -0
  39. package/tests/helpers/download-fixtures.js +125 -0
  40. package/tests/helpers/download-server.js +109 -0
  41. package/tests/helpers/e2e-browser.js +67 -0
  42. package/tests/helpers/file-modes.js +31 -0
  43. package/tests/helpers/fixture-server.js +56 -0
  44. package/tests/helpers/mocks.js +63 -66
  45. package/tests/helpers/readiness-server.js +96 -0
  46. package/tests/helpers/temp-directory.js +44 -0
  47. package/tests/helpers/trace-fixtures.js +198 -0
  48. package/tests/helpers/trace-server.js +117 -0
  49. package/tests/unit/core/dialog-manager.test.js +118 -10
  50. package/tests/unit/core/navigation-manager.test.js +162 -0
  51. package/tests/unit/core/readiness.test.js +517 -0
  52. package/tests/unit/downloads/attach.test.js +220 -0
  53. package/tests/unit/downloads/destination.test.js +114 -0
  54. package/tests/unit/downloads/manager.test.js +354 -0
  55. package/tests/unit/downloads/naming.test.js +174 -0
  56. package/tests/unit/downloads/store.test.js +215 -0
  57. package/tests/unit/interactions/click-activation.test.js +283 -0
  58. package/tests/unit/interactions/click-result.test.js +90 -0
  59. package/tests/unit/interactions/click.test.js +409 -200
  60. package/tests/unit/tests/tracing.test.js +299 -0
  61. package/tests/unit/traces/bundle.test.js +260 -0
  62. package/tests/unit/traces/reader.test.js +208 -0
  63. package/tests/unit/traces/recorder.test.js +562 -0
  64. package/tests/unit/traces/redaction.test.js +170 -0
  65. package/tests/unit/traces/schema.test.js +92 -0
  66. package/tests/unit/traces/viewer.test.js +289 -0
@@ -14,7 +14,6 @@
14
14
  "0b48b63049c42591": 1,
15
15
  "0b8c71391c107a5e": 1,
16
16
  "0ba2d165b56db0b3": 1,
17
- "0c8a29320e45bdfa": 1,
18
17
  "0ca2d9c2f2b44d6c": 1,
19
18
  "0caa402c4eae32d8": 1,
20
19
  "0fd23be8410c2c95": 1,
@@ -28,7 +27,6 @@
28
27
  "16340a16ed52849e": 1,
29
28
  "17c26c0db2d35ad1": 1,
30
29
  "193b46c0e7f72053": 1,
31
- "1ac9fbd87efdb313": 1,
32
30
  "1ad4dc7e19a77f68": 1,
33
31
  "1c1baf1bc8ea0eae": 1,
34
32
  "1cbd39b416a2b94b": 1,
@@ -77,15 +75,12 @@
77
75
  "4c3b94caa28045d0": 1,
78
76
  "4e090d6bb30e6551": 1,
79
77
  "4f3c541b3f3e3f23": 1,
80
- "4fe5663c69a426cb": 1,
81
- "505d98bd14b63a6c": 1,
82
78
  "5085765e1f103f3e": 1,
83
79
  "5184c90c9f8406c3": 1,
84
80
  "520a28ee2ced56c2": 1,
85
81
  "53519ec61a6ba319": 1,
86
82
  "53f7e3f3e444a37d": 1,
87
83
  "5587ec0cdb7b96e6": 1,
88
- "559b5241047cd753": 1,
89
84
  "562ebfb882d4aa8f": 1,
90
85
  "56b5df15ee61887d": 1,
91
86
  "56b7675e4ce57335": 1,
@@ -103,10 +98,10 @@
103
98
  "6098544c77ff17a2": 1,
104
99
  "60d0c58a01fc8746": 1,
105
100
  "61bcadcd8dc9f63e": 1,
106
- "63a3b672c8d8b50b": 1,
107
101
  "63ff613b1780c67a": 1,
108
102
  "642cc89ed49f8a21": 1,
109
103
  "6479c3604c153ec4": 1,
104
+ "6699064d6e8e09ba": 1,
110
105
  "66fb97eebd7ff0dc": 1,
111
106
  "67a95e428b8fa31c": 1,
112
107
  "6880d620e0e67229": 1,
@@ -119,6 +114,7 @@
119
114
  "6fa62e2642cc73c3": 1,
120
115
  "7009c5e9bd61dbe8": 1,
121
116
  "709348ea44394b52": 1,
117
+ "72705e6a2212d7b2": 1,
122
118
  "731fbc5d4188839a": 1,
123
119
  "73dbf56d17ab79ed": 1,
124
120
  "760d9925f6e3ca85": 1,
@@ -141,28 +137,22 @@
141
137
  "8678919f1350f154": 1,
142
138
  "86aae81b1aa0d21b": 1,
143
139
  "87484a2744e09748": 1,
144
- "87514cf1299c9c0e": 1,
145
140
  "876d2c8edfc8c40a": 1,
146
141
  "8b013ac20e436d76": 1,
147
142
  "8c2b1408886c1db1": 1,
148
- "8cb9a984bf19a062": 1,
149
143
  "8ec18cdbd4a96518": 1,
150
- "8ecf2e554a04d2ef": 1,
151
144
  "91049efadc65169b": 1,
152
145
  "9134185980de8a17": 1,
153
146
  "916833bafca4fa63": 1,
154
147
  "91a538e4a0c28f25": 1,
155
- "929fba5228e9fdac": 1,
156
148
  "92ddafa7e65be7eb": 1,
157
149
  "94821a4d1b745dfd": 1,
158
- "94d89888ac9da9a9": 1,
159
150
  "97c5a112fdde7be8": 1,
160
151
  "98cc22079feaa004": 1,
161
152
  "990d87f21678fa96": 1,
162
153
  "9a72b1c2f22156ca": 1,
163
154
  "9b6ef4df04562546": 1,
164
155
  "9c055dfd90c369bf": 1,
165
- "9cb19f1b972e811e": 1,
166
156
  "9ee57dcd5e7a6691": 1,
167
157
  "9fbb8296259dedbe": 1,
168
158
  "a0183465782b0b15": 1,
@@ -175,7 +165,6 @@
175
165
  "a78d76908162699e": 1,
176
166
  "a9bd4ffef6da5faf": 1,
177
167
  "a9d71c2e50f1b832": 1,
178
- "abe5a58f2b2cc593": 1,
179
168
  "acc95d694eb2f581": 1,
180
169
  "ad4469f2e8131535": 1,
181
170
  "ad4cfa76daa1b6fc": 1,
@@ -188,7 +177,6 @@
188
177
  "b2a0686231220c8a": 1,
189
178
  "b35ff46c365dcd18": 1,
190
179
  "b41581af204af40e": 1,
191
- "b62f3449cde7991d": 1,
192
180
  "b71fa5250da28739": 1,
193
181
  "b740ecac685a15fe": 1,
194
182
  "b8c2d21f2a26f77d": 1,
@@ -209,6 +197,7 @@
209
197
  "c415ffcd004d6518": 1,
210
198
  "c461a83b51eb7740": 1,
211
199
  "c9cf3c28bed33a5b": 1,
200
+ "cab590a1d04b5a05": 1,
212
201
  "cbd444e9a6a9072e": 1,
213
202
  "cd49f39b43d14635": 1,
214
203
  "cebff7d1cc87f646": 1,
@@ -221,7 +210,6 @@
221
210
  "d1a4a8e6a523b752": 1,
222
211
  "d1e5c2961267399a": 1,
223
212
  "d2ed3d02dd6a2d8c": 1,
224
- "d4eec99f58906a63": 1,
225
213
  "d6157a86fea41907": 1,
226
214
  "d6fa9884eee815f0": 1,
227
215
  "d86bafb915488255": 1,
@@ -239,7 +227,6 @@
239
227
  "ea1195048ded896c": 1,
240
228
  "ea88711c5deb1fe5": 1,
241
229
  "eb9a70c2a21094ff": 1,
242
- "ebd08637a2d7bcf3": 1,
243
230
  "ebe03c5f8c2f5147": 1,
244
231
  "ee19c8b9b332ee98": 1,
245
232
  "eeca0ccf8d821f21": 1,
@@ -253,11 +240,11 @@
253
240
  "f4bfa7e503443cd1": 1,
254
241
  "f6678d6748e9c902": 1,
255
242
  "f67a07a611f39cb2": 1,
243
+ "f7b1f9d699bcd580": 1,
256
244
  "f911821aa7dda47f": 1,
257
245
  "f97bf515bfd95829": 1,
258
246
  "fae844842fba705b": 1,
259
247
  "faf76ec43b2dbbaa": 1,
260
- "fb3a2e9ace145907": 1,
261
248
  "fc6ed7d760c98f51": 1,
262
249
  "fccfd8b004c12dd4": 1,
263
250
  "fe859df9f97c75d4": 1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0915dd8: Add managed downloads, portable traces and truthful click results.
8
+
9
+ `downloads` is now accepted by `launchBrowser()`, `connectBrowser()` and
10
+ `launchRealBrowser()`, and by `commander.configureDownloads()` on a browser you
11
+ already have. The manager owns the file rather than the page: `capture()` starts
12
+ listening before the action runs so a fast download cannot be missed, a caller
13
+ can choose the saved name and have a bare UUID given the extension its content
14
+ proves, and the file is still there after the context and the browser are gone.
15
+ Failed and cancelled downloads report the failure instead of a path, and
16
+ `browser-commander/tests` keeps captured files in the run's artifact directory.
17
+
18
+ `startTrace()` records a session into a schema-versioned bundle - an ordered
19
+ NDJSON timeline, named checkpoints with full HTML, live control state and frame
20
+ metadata, and the DOM mutation batches between them - which `readTrace()`,
21
+ `diffControlState()` and the offline `writeTraceViewer()` read back with scripts
22
+ and network disabled. Redaction runs before anything is written, a truncated or
23
+ interrupted run still produces a readable partial trace, and
24
+ `browser-commander/tests` supports `trace: 'retain-on-failure'` in place of its
25
+ own artifact mechanism.
26
+
27
+ `clickElement()` now reports what was observed: `status` is `succeeded`,
28
+ `failed`, `timed_out`, `interrupted` or `unverified`, `effect` is `confirmed`,
29
+ `not-observed` or `contradicted`, and `evidence` carries the reason for both.
30
+ The `clicked` and `verified` booleans remain and are derived conservatively, so
31
+ a click that changed nothing is no longer reported as verified. `waitForReady()`
32
+ composes URL, network, DOM, image and custom checks under one deadline and
33
+ returns the same per-check evidence. `noAutoScroll` is deprecated in favour of
34
+ the `scroll` axis; `scroll: 'none'` now fails with a `ScrollConstraintError` on
35
+ engines that cannot click without scrolling instead of scrolling anyway.
36
+
3
37
  ## 0.17.2
4
38
 
5
39
  ### Patch Changes
package/README.md CHANGED
@@ -599,6 +599,90 @@ await keyDown({ page, engine: 'playwright', key: 'Control' });
599
599
  await keyUp({ page, engine: 'playwright', key: 'Control' });
600
600
  ```
601
601
 
602
+ ### Managed Downloads
603
+
604
+ A download that only exists while the browser is open is not a download. Ask for
605
+ `downloads` at any entry point - `launchBrowser()`, `connectBrowser()`,
606
+ `launchRealBrowser()` or `commander.configureDownloads()` - and the manager owns
607
+ the file from then on:
608
+
609
+ ```javascript
610
+ const { commander, downloads } = await launchBrowser({
611
+ downloads: { directory: '/tmp/reports', conflict: 'rename' },
612
+ });
613
+
614
+ // capture() starts listening before the action runs, so a download that
615
+ // finishes in 5ms cannot slip past the registration.
616
+ const artifact = await downloads.capture({
617
+ action: () => commander.clickButton({ selector: '#export' }),
618
+ filename: 'q3-report.pdf', // the page's UUID name gets the caller's name
619
+ timeout: 30000,
620
+ });
621
+
622
+ console.log(artifact.path, artifact.bytes, artifact.checksum);
623
+
624
+ await commander.destroy();
625
+ // The file is still there: it outlives the page, the context and the browser.
626
+ ```
627
+
628
+ `downloads.on(DOWNLOAD_EVENT.COMPLETED, ...)` observes every download in the
629
+ session, including one a person started by hand in a visible browser, and each
630
+ download is reported exactly once whether it was captured or merely observed. A
631
+ failed or cancelled download raises the failure rather than returning a path.
632
+
633
+ ### Portable Traces
634
+
635
+ `startTrace()` records a session into a schema-versioned bundle any of the three
636
+ languages can read - the manifest, an ordered NDJSON timeline, per-checkpoint
637
+ DOM snapshots and the mutation batches between them:
638
+
639
+ ```javascript
640
+ import { startTrace, readTrace, writeTraceViewer } from 'browser-commander';
641
+
642
+ const trace = await startTrace({
643
+ commander,
644
+ output: '/tmp/traces/checkout',
645
+ mode: 'continuous', // keeps DOM mutations between checkpoints
646
+ privacy: { redact: [/password/i, /token/i] },
647
+ });
648
+
649
+ await commander.goto({ url: 'https://example.com/checkout' });
650
+ await trace.checkpoint('cart');
651
+ await commander.clickButton({ selector: '#pay' });
652
+ await trace.checkpoint('paid');
653
+
654
+ const { path } = await trace.stop();
655
+ const bundle = await readTrace(path);
656
+ await writeTraceViewer(bundle, '/tmp/traces/checkout/viewer.html');
657
+ ```
658
+
659
+ Redaction runs before anything reaches disk, and a run cut short by a closed
660
+ page or a size limit still leaves a readable partial trace. The viewer seeks,
661
+ diffs and replays the captured DOM with scripts and network disabled.
662
+
663
+ ### Truthful Click Results
664
+
665
+ `clickElement()` reports what was observed, not what was attempted:
666
+
667
+ ```javascript
668
+ const result = await clickElement({
669
+ page,
670
+ engine,
671
+ locatorOrElement: '#submit',
672
+ });
673
+
674
+ result.status; // 'succeeded' | 'failed' | 'timed_out' | 'interrupted' | 'unverified'
675
+ result.effect; // 'confirmed' | 'not-observed' | 'contradicted'
676
+ result.evidence; // why status and effect say what they say
677
+ result.clicked; // still here: whether the click reached the element
678
+ result.verified; // still here, now derived from effect === 'confirmed'
679
+ ```
680
+
681
+ The `scroll` axis (`auto`, `preserve`, `none`) replaces the deprecated
682
+ `noAutoScroll` flag. `scroll: 'none'` never scrolls: on an engine that cannot
683
+ deliver a click without scrolling it throws `ScrollConstraintError` naming the
684
+ alternatives, rather than scrolling the page and reporting success.
685
+
602
686
  ### commander.destroy()
603
687
 
604
688
  ```javascript
package/eslint.config.js CHANGED
@@ -22,6 +22,7 @@ export default [
22
22
  __filename: 'readonly',
23
23
  // Node.js 18+ globals
24
24
  fetch: 'readonly',
25
+ performance: 'readonly',
25
26
  // Web/Node.js shared globals
26
27
  setTimeout: 'readonly',
27
28
  clearTimeout: 'readonly',
@@ -50,6 +51,10 @@ export default [
50
51
  // Chrome verbatim rather than executed by Node).
51
52
  document: 'readonly',
52
53
  window: 'readonly',
54
+ location: 'readonly',
55
+ MutationObserver: 'readonly',
56
+ DOMParser: 'readonly',
57
+ Node: 'readonly',
53
58
  navigator: 'readonly',
54
59
  Navigator: 'readonly',
55
60
  screen: 'readonly',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
4
4
  "description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -16,6 +16,9 @@
16
16
  "test:e2e:playwright": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/playwright.e2e.test.js",
17
17
  "test:e2e:puppeteer": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/puppeteer.e2e.test.js",
18
18
  "test:e2e:parity": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/fingerprint-parity.e2e.test.js",
19
+ "test:e2e:click-readiness": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/click-readiness.e2e.test.js",
20
+ "test:e2e:downloads": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/downloads.e2e.test.js",
21
+ "test:e2e:traces": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/traces.e2e.test.js",
19
22
  "test:all": "npm run test:unit && npm run test:e2e",
20
23
  "lint": "eslint .",
21
24
  "lint:fix": "eslint . --fix",
package/src/bindings.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  setContent,
12
12
  waitForNavigation,
13
13
  waitForPageReady,
14
+ waitForReady,
14
15
  waitAfterAction,
15
16
  } from './browser/navigation.js';
16
17
  import { emulateMedia } from './browser/media.js';
@@ -137,6 +138,15 @@ export function createBoundFunctions(options = {}) {
137
138
  log,
138
139
  wait: waitBound,
139
140
  });
141
+ const waitForReadyBound = (opts) =>
142
+ waitForReady({
143
+ ...opts,
144
+ page,
145
+ engine,
146
+ navigationManager,
147
+ networkTracker,
148
+ log,
149
+ });
140
150
  const waitAfterActionBound = (opts) =>
141
151
  waitAfterAction({
142
152
  ...opts,
@@ -199,7 +209,8 @@ export function createBoundFunctions(options = {}) {
199
209
  scrollIntoViewIfNeeded({ ...opts, page, engine, wait: waitBound, log });
200
210
 
201
211
  // Bound click - now navigation-aware
202
- const clickElementBound = (opts) => clickElement({ ...opts, engine, log });
212
+ const clickElementBound = (opts) =>
213
+ clickElement({ ...opts, page, engine, log });
203
214
  const clickButtonBound = (opts) =>
204
215
  clickButton({
205
216
  ...opts,
@@ -323,6 +334,7 @@ export function createBoundFunctions(options = {}) {
323
334
  getUrl: getUrlBound,
324
335
  waitForNavigation: waitForNavigationBound,
325
336
  waitForPageReady: waitForPageReadyBound,
337
+ waitForReady: waitForReadyBound,
326
338
  waitAfterAction: waitAfterActionBound,
327
339
  getAttribute: getAttributeWrapped,
328
340
  isVisible: isVisibleWrapped,
@@ -1,3 +1,4 @@
1
+ import { attachDownloads } from '../downloads/attach.js';
1
2
  import {
2
3
  loadStorageState,
3
4
  restorePlaywrightStorageState,
@@ -109,7 +110,8 @@ async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
109
110
  * @param {Object[]|string|Object} [options.storageState] - Playwright-compatible state path or object
110
111
  * @param {Object[]} [options.seedCookies] - Cookies to seed after connecting
111
112
  * @param {boolean} [options.verbose=false] - Enable connection logging
112
- * @returns {Promise<{browser: Object, page: Object}>} Raw browser and page handles
113
+ * @param {boolean|Object} [options.downloads] - Manage downloads: true for defaults, or {directory, persist, conflict}
114
+ * @returns {Promise<{browser: Object, page: Object, downloads: Object|null}>} Raw handles and the download manager when one was requested
113
115
  */
114
116
  export async function connectBrowser(options = {}) {
115
117
  return await connectBrowserWithDependencies(options);
@@ -120,7 +122,7 @@ export async function connectBrowser(options = {}) {
120
122
  *
121
123
  * @param {Object} options - See {@link connectBrowser}
122
124
  * @param {Object} dependencies - Optional engine module loaders
123
- * @returns {Promise<{browser: Object, page: Object}>} Raw browser and page handles
125
+ * @returns {Promise<{browser: Object, page: Object, downloads: Object|null}>} Raw handles and the download manager when one was requested
124
126
  */
125
127
  export async function connectBrowserWithDependencies(
126
128
  options = {},
@@ -157,7 +159,17 @@ export async function connectBrowserWithDependencies(
157
159
  if (verbose) {
158
160
  console.log(`Connected to browser with ${engine} engine`);
159
161
  }
160
- return result;
162
+
163
+ // An attached browser gets the same managed lifecycle as a launched one:
164
+ // the manager is built from the browser and page, not from how we got them.
165
+ const downloads = await attachDownloads({
166
+ engine,
167
+ browser: result.browser,
168
+ page: result.page,
169
+ downloads: normalizedOptions.downloads,
170
+ });
171
+
172
+ return { ...result, downloads };
161
173
  }
162
174
 
163
175
  export { buildPlaywrightConnectOptions, buildPuppeteerConnectOptions };
@@ -8,6 +8,7 @@ import {
8
8
  resolveChromeArgs,
9
9
  } from './launch-options.js';
10
10
  import { applyFingerprint } from '../fingerprint/apply.js';
11
+ import { attachDownloads } from '../downloads/attach.js';
11
12
  import {
12
13
  loadStorageState,
13
14
  restorePlaywrightStorageState,
@@ -31,7 +32,8 @@ import {
31
32
  * @param {string|Object} [options.storageState] - Playwright-compatible storage state path or object
32
33
  * @param {Object} [options.fingerprint] - Environment fields to present to pages, such as userAgent, timezone, locale, hardwareConcurrency or screen. Applied over CDP after launch; see src/fingerprint/profile.js for the full field list and presets.js for ready-made profiles.
33
34
  * @param {boolean} [options.automationParity=true] - Keep the command line indistinguishable from a hand-started Chrome. Turning this off restores the pre-parity switches and makes navigator.webdriver true.
34
- * @returns {Promise<Object>} - Object with browser and page
35
+ * @param {boolean|Object} [options.downloads] - Manage downloads: true for defaults, or {directory, persist, conflict}. The directory may be an absolute path, 'user-downloads' or 'temporary'.
36
+ * @returns {Promise<{browser: Object, page: Object, downloads: Object|null}>} - Browser, page and the download manager when one was requested
35
37
  */
36
38
  export async function launchBrowser(options = {}) {
37
39
  const {
@@ -49,6 +51,7 @@ export async function launchBrowser(options = {}) {
49
51
  storageState,
50
52
  fingerprint,
51
53
  automationParity = true,
54
+ downloads,
52
55
  } = options;
53
56
 
54
57
  const resolvedChromeArgs = resolveChromeArgs({
@@ -169,5 +172,14 @@ export async function launchBrowser(options = {}) {
169
172
  }
170
173
  }
171
174
 
172
- return { browser, page };
175
+ // Downloads are armed before the caller can navigate: a download triggered
176
+ // by the first page load still lands in the managed directory.
177
+ const downloadManager = await attachDownloads({
178
+ engine,
179
+ browser,
180
+ page,
181
+ downloads,
182
+ });
183
+
184
+ return { browser, page, downloads: downloadManager };
173
185
  }
@@ -8,6 +8,13 @@
8
8
  import { TIMING } from '../core/constants.js';
9
9
  import { isNavigationError } from '../core/navigation-safety.js';
10
10
  import { isActionStoppedError } from '../core/page-trigger-manager.js';
11
+ import {
12
+ READINESS_STATUS,
13
+ createDeadline,
14
+ networkIdleFor,
15
+ runReadinessChecks,
16
+ urlStableFor,
17
+ } from '../core/readiness.js';
11
18
 
12
19
  /**
13
20
  * Default verification function for navigation operations.
@@ -492,6 +499,62 @@ export async function waitForPageReady(options = {}) {
492
499
  return true;
493
500
  }
494
501
 
502
+ /**
503
+ * Wait for the page to be ready and return structured evidence.
504
+ *
505
+ * Unlike {@link waitForPageReady}, which answers with a single boolean, this
506
+ * reports which checks passed, which failed, which were skipped and which never
507
+ * got to run before the shared deadline expired.
508
+ *
509
+ * @param {Object} options - Configuration options
510
+ * @param {Object} options.page - Browser page object
511
+ * @param {string} [options.engine] - Engine type
512
+ * @param {Object} [options.navigationManager] - NavigationManager instance
513
+ * @param {Object} [options.networkTracker] - NetworkTracker instance
514
+ * @param {Function} [options.log] - Logger instance
515
+ * @param {number} [options.timeout=30000] - Total budget in milliseconds
516
+ * @param {string} [options.reason='page ready'] - Reason for waiting
517
+ * @param {Array} [options.checks] - Composable readiness checks
518
+ * @returns {Promise<Object>} Structured readiness result
519
+ */
520
+ export async function waitForReady(options = {}) {
521
+ const {
522
+ page,
523
+ engine,
524
+ navigationManager,
525
+ networkTracker,
526
+ log = { debug: () => {} },
527
+ timeout = 30000,
528
+ reason = 'page ready',
529
+ checks,
530
+ } = options;
531
+
532
+ if (navigationManager?.waitForReady) {
533
+ return navigationManager.waitForReady({ timeout, reason, checks });
534
+ }
535
+
536
+ const deadline = createDeadline({ timeout });
537
+ const result = await runReadinessChecks({
538
+ checks: checks ?? [urlStableFor(), networkIdleFor()],
539
+ deadline,
540
+ context: {
541
+ page,
542
+ engine,
543
+ log,
544
+ networkTracker,
545
+ getAdapter: async () => {
546
+ const { createEngineAdapter } =
547
+ await import('../core/engine-adapter.js');
548
+ return createEngineAdapter(page, engine);
549
+ },
550
+ },
551
+ });
552
+
553
+ return { ...result, reason, url: page?.url?.() ?? null };
554
+ }
555
+
556
+ export { READINESS_STATUS };
557
+
495
558
  /**
496
559
  * Wait for any ongoing navigation and network requests to complete.
497
560
  * Use this after actions that might trigger navigation (like clicks).
@@ -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,