browser-commander 0.17.1 → 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 (70) hide show
  1. package/.jscpd-baseline.json +7 -18
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +84 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +4 -1
  6. package/scripts/version-and-commit.mjs +13 -2
  7. package/src/bindings.js +13 -1
  8. package/src/browser/connector.js +15 -3
  9. package/src/browser/launcher.js +14 -2
  10. package/src/browser/navigation.js +63 -0
  11. package/src/core/dialog-manager.js +48 -0
  12. package/src/core/navigation-manager.js +83 -87
  13. package/src/core/navigation-readiness.js +133 -0
  14. package/src/core/readiness.js +478 -0
  15. package/src/downloads/attach.js +58 -0
  16. package/src/downloads/destination.js +125 -0
  17. package/src/downloads/manager.js +453 -0
  18. package/src/downloads/naming.js +246 -0
  19. package/src/downloads/sources.js +188 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +72 -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 +323 -0
  29. package/src/traces/observers.js +246 -0
  30. package/src/traces/page-capture.js +365 -0
  31. package/src/traces/reader.js +226 -0
  32. package/src/traces/recorder.js +437 -0
  33. package/src/traces/redaction.js +231 -0
  34. package/src/traces/schema.js +165 -0
  35. package/src/traces/viewer.js +302 -0
  36. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  37. package/tests/e2e/downloads.e2e.test.js +188 -0
  38. package/tests/e2e/traces.e2e.test.js +457 -0
  39. package/tests/helpers/click-fixtures.js +62 -0
  40. package/tests/helpers/download-fixtures.js +125 -0
  41. package/tests/helpers/download-server.js +109 -0
  42. package/tests/helpers/e2e-browser.js +67 -0
  43. package/tests/helpers/file-modes.js +31 -0
  44. package/tests/helpers/fixture-server.js +56 -0
  45. package/tests/helpers/mocks.js +63 -66
  46. package/tests/helpers/readiness-server.js +96 -0
  47. package/tests/helpers/temp-directory.js +44 -0
  48. package/tests/helpers/trace-fixtures.js +198 -0
  49. package/tests/helpers/trace-server.js +117 -0
  50. package/tests/unit/core/dialog-manager.test.js +118 -10
  51. package/tests/unit/core/navigation-manager.test.js +162 -0
  52. package/tests/unit/core/readiness.test.js +517 -0
  53. package/tests/unit/downloads/attach.test.js +220 -0
  54. package/tests/unit/downloads/destination.test.js +114 -0
  55. package/tests/unit/downloads/manager.test.js +354 -0
  56. package/tests/unit/downloads/naming.test.js +174 -0
  57. package/tests/unit/downloads/store.test.js +215 -0
  58. package/tests/unit/interactions/click-activation.test.js +283 -0
  59. package/tests/unit/interactions/click-result.test.js +90 -0
  60. package/tests/unit/interactions/click.test.js +409 -200
  61. package/tests/unit/scripts/check-release-format.test.js +4 -1
  62. package/tests/unit/scripts/push-failure-classifier.test.js +153 -0
  63. package/tests/unit/scripts/push-with-rebase-retry.test.js +302 -0
  64. package/tests/unit/tests/tracing.test.js +299 -0
  65. package/tests/unit/traces/bundle.test.js +260 -0
  66. package/tests/unit/traces/reader.test.js +208 -0
  67. package/tests/unit/traces/recorder.test.js +562 -0
  68. package/tests/unit/traces/redaction.test.js +170 -0
  69. package/tests/unit/traces/schema.test.js +92 -0
  70. package/tests/unit/traces/viewer.test.js +289 -0
@@ -3,6 +3,7 @@
3
3
  "fingerprints": {
4
4
  "00d381dd611ae8ac": 1,
5
5
  "029a62ef1f48e856": 1,
6
+ "03241c1f33f40d14": 1,
6
7
  "038170e72e0af1f5": 1,
7
8
  "04b28d01d0bb47dc": 1,
8
9
  "04f605be138bc350": 1,
@@ -13,7 +14,6 @@
13
14
  "0b48b63049c42591": 1,
14
15
  "0b8c71391c107a5e": 1,
15
16
  "0ba2d165b56db0b3": 1,
16
- "0c8a29320e45bdfa": 1,
17
17
  "0ca2d9c2f2b44d6c": 1,
18
18
  "0caa402c4eae32d8": 1,
19
19
  "0fd23be8410c2c95": 1,
@@ -27,9 +27,9 @@
27
27
  "16340a16ed52849e": 1,
28
28
  "17c26c0db2d35ad1": 1,
29
29
  "193b46c0e7f72053": 1,
30
- "1ac9fbd87efdb313": 1,
31
30
  "1ad4dc7e19a77f68": 1,
32
31
  "1c1baf1bc8ea0eae": 1,
32
+ "1cbd39b416a2b94b": 1,
33
33
  "1d61f5c49f12d2dc": 1,
34
34
  "1ef570563d4e5a08": 1,
35
35
  "1f2104c950b12a30": 1,
@@ -75,15 +75,12 @@
75
75
  "4c3b94caa28045d0": 1,
76
76
  "4e090d6bb30e6551": 1,
77
77
  "4f3c541b3f3e3f23": 1,
78
- "4fe5663c69a426cb": 1,
79
- "505d98bd14b63a6c": 1,
80
78
  "5085765e1f103f3e": 1,
81
79
  "5184c90c9f8406c3": 1,
82
80
  "520a28ee2ced56c2": 1,
83
81
  "53519ec61a6ba319": 1,
84
82
  "53f7e3f3e444a37d": 1,
85
83
  "5587ec0cdb7b96e6": 1,
86
- "559b5241047cd753": 1,
87
84
  "562ebfb882d4aa8f": 1,
88
85
  "56b5df15ee61887d": 1,
89
86
  "56b7675e4ce57335": 1,
@@ -98,12 +95,13 @@
98
95
  "5ae9b1430fc38daf": 1,
99
96
  "602bfd129bba900e": 1,
100
97
  "608e1dd27138ec6f": 1,
98
+ "6098544c77ff17a2": 1,
101
99
  "60d0c58a01fc8746": 1,
102
100
  "61bcadcd8dc9f63e": 1,
103
- "63a3b672c8d8b50b": 1,
104
101
  "63ff613b1780c67a": 1,
105
102
  "642cc89ed49f8a21": 1,
106
103
  "6479c3604c153ec4": 1,
104
+ "6699064d6e8e09ba": 1,
107
105
  "66fb97eebd7ff0dc": 1,
108
106
  "67a95e428b8fa31c": 1,
109
107
  "6880d620e0e67229": 1,
@@ -116,6 +114,7 @@
116
114
  "6fa62e2642cc73c3": 1,
117
115
  "7009c5e9bd61dbe8": 1,
118
116
  "709348ea44394b52": 1,
117
+ "72705e6a2212d7b2": 1,
119
118
  "731fbc5d4188839a": 1,
120
119
  "73dbf56d17ab79ed": 1,
121
120
  "760d9925f6e3ca85": 1,
@@ -138,29 +137,22 @@
138
137
  "8678919f1350f154": 1,
139
138
  "86aae81b1aa0d21b": 1,
140
139
  "87484a2744e09748": 1,
141
- "87514cf1299c9c0e": 1,
142
140
  "876d2c8edfc8c40a": 1,
143
141
  "8b013ac20e436d76": 1,
144
142
  "8c2b1408886c1db1": 1,
145
- "8c36ed4fc291ae47": 1,
146
- "8cb9a984bf19a062": 1,
147
143
  "8ec18cdbd4a96518": 1,
148
- "8ecf2e554a04d2ef": 1,
149
144
  "91049efadc65169b": 1,
150
145
  "9134185980de8a17": 1,
151
146
  "916833bafca4fa63": 1,
152
147
  "91a538e4a0c28f25": 1,
153
- "929fba5228e9fdac": 1,
154
148
  "92ddafa7e65be7eb": 1,
155
149
  "94821a4d1b745dfd": 1,
156
- "94d89888ac9da9a9": 1,
157
150
  "97c5a112fdde7be8": 1,
158
151
  "98cc22079feaa004": 1,
159
152
  "990d87f21678fa96": 1,
160
153
  "9a72b1c2f22156ca": 1,
161
154
  "9b6ef4df04562546": 1,
162
155
  "9c055dfd90c369bf": 1,
163
- "9cb19f1b972e811e": 1,
164
156
  "9ee57dcd5e7a6691": 1,
165
157
  "9fbb8296259dedbe": 1,
166
158
  "a0183465782b0b15": 1,
@@ -173,7 +165,6 @@
173
165
  "a78d76908162699e": 1,
174
166
  "a9bd4ffef6da5faf": 1,
175
167
  "a9d71c2e50f1b832": 1,
176
- "abe5a58f2b2cc593": 1,
177
168
  "acc95d694eb2f581": 1,
178
169
  "ad4469f2e8131535": 1,
179
170
  "ad4cfa76daa1b6fc": 1,
@@ -186,7 +177,6 @@
186
177
  "b2a0686231220c8a": 1,
187
178
  "b35ff46c365dcd18": 1,
188
179
  "b41581af204af40e": 1,
189
- "b62f3449cde7991d": 1,
190
180
  "b71fa5250da28739": 1,
191
181
  "b740ecac685a15fe": 1,
192
182
  "b8c2d21f2a26f77d": 1,
@@ -207,6 +197,7 @@
207
197
  "c415ffcd004d6518": 1,
208
198
  "c461a83b51eb7740": 1,
209
199
  "c9cf3c28bed33a5b": 1,
200
+ "cab590a1d04b5a05": 1,
210
201
  "cbd444e9a6a9072e": 1,
211
202
  "cd49f39b43d14635": 1,
212
203
  "cebff7d1cc87f646": 1,
@@ -219,7 +210,6 @@
219
210
  "d1a4a8e6a523b752": 1,
220
211
  "d1e5c2961267399a": 1,
221
212
  "d2ed3d02dd6a2d8c": 1,
222
- "d4eec99f58906a63": 1,
223
213
  "d6157a86fea41907": 1,
224
214
  "d6fa9884eee815f0": 1,
225
215
  "d86bafb915488255": 1,
@@ -237,7 +227,6 @@
237
227
  "ea1195048ded896c": 1,
238
228
  "ea88711c5deb1fe5": 1,
239
229
  "eb9a70c2a21094ff": 1,
240
- "ebd08637a2d7bcf3": 1,
241
230
  "ebe03c5f8c2f5147": 1,
242
231
  "ee19c8b9b332ee98": 1,
243
232
  "eeca0ccf8d821f21": 1,
@@ -251,11 +240,11 @@
251
240
  "f4bfa7e503443cd1": 1,
252
241
  "f6678d6748e9c902": 1,
253
242
  "f67a07a611f39cb2": 1,
243
+ "f7b1f9d699bcd580": 1,
254
244
  "f911821aa7dda47f": 1,
255
245
  "f97bf515bfd95829": 1,
256
246
  "fae844842fba705b": 1,
257
247
  "faf76ec43b2dbbaa": 1,
258
- "fb3a2e9ace145907": 1,
259
248
  "fc6ed7d760c98f51": 1,
260
249
  "fccfd8b004c12dd4": 1,
261
250
  "fe859df9f97c75d4": 1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
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
+
37
+ ## 0.17.2
38
+
39
+ ### Patch Changes
40
+
41
+ - 539ae64: Recover a release push that lost the race to `main` instead of failing the run.
42
+
43
+ The `main-writer` concurrency group serialises the three release jobs correctly,
44
+ but `actions/checkout` checks out `github.sha`, so every writer after the first
45
+ holds a tree one commit behind `main` and its push is rejected as
46
+ non-fast-forward. Serialisation buys ordering, not freshness. Every push to
47
+ `main` now rebases and retries — after classifying the rejection, because a
48
+ GH006/GH013 ruleset rejection also prints "rejected" and can never be rebased
49
+ away.
50
+
3
51
  ## 0.17.1
4
52
 
5
53
  ### 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.1",
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",
@@ -16,6 +16,10 @@ import { readFileSync, appendFileSync, readdirSync } from 'fs';
16
16
 
17
17
  import { assertGeneratedFilesAreFormatted } from './check-release-format.mjs';
18
18
 
19
+ import {
20
+ createGitRunner,
21
+ pushWithRebaseRetry,
22
+ } from '../../scripts/push-with-rebase-retry.mjs';
19
23
  import {
20
24
  loadCommandStream,
21
25
  loadLinoArguments,
@@ -232,8 +236,15 @@ async function main() {
232
236
  const escapedMessage = commitMessage.replace(/"/g, '\\"');
233
237
  await $`git commit -m "${escapedMessage}"`;
234
238
 
235
- // Push directly to main
236
- await $`git push origin main`;
239
+ // Push directly to main.
240
+ //
241
+ // The pre-emptive rebase above runs before the version bump, so it
242
+ // cannot cover a writer that lands between the bump and this push.
243
+ // pushWithRebaseRetry closes that window and, just as importantly,
244
+ // distinguishes a lost race from a branch-protection rejection instead
245
+ // of retrying one as if it were the other.
246
+ const git = await createGitRunner();
247
+ await pushWithRebaseRetry({ git, branch: 'main' });
237
248
 
238
249
  console.log('\u2705 Version bump committed and pushed to main');
239
250
  setOutput('version_committed', 'true');
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,