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
@@ -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,
@@ -48,6 +46,7 @@
48
46
  "266b9cb95f783cdf": 1,
49
47
  "26bb09996580437c": 1,
50
48
  "26ebb2e251c9a39e": 1,
49
+ "29edfb9e08094991": 1,
51
50
  "2a78ee4b05679c8d": 1,
52
51
  "2b1a759e2cfd21a2": 1,
53
52
  "2c0f8b5c6f28420b": 1,
@@ -57,6 +56,7 @@
57
56
  "2f9e15467cc2f12a": 1,
58
57
  "3101802d3ea65339": 1,
59
58
  "33357ce94e07cc4b": 1,
59
+ "35cf2769645f3234": 1,
60
60
  "37bd4114d0089f49": 1,
61
61
  "389df6fb4b8315a1": 1,
62
62
  "39eaada6d1879f4a": 1,
@@ -77,15 +77,12 @@
77
77
  "4c3b94caa28045d0": 1,
78
78
  "4e090d6bb30e6551": 1,
79
79
  "4f3c541b3f3e3f23": 1,
80
- "4fe5663c69a426cb": 1,
81
- "505d98bd14b63a6c": 1,
82
80
  "5085765e1f103f3e": 1,
83
81
  "5184c90c9f8406c3": 1,
84
82
  "520a28ee2ced56c2": 1,
85
83
  "53519ec61a6ba319": 1,
86
84
  "53f7e3f3e444a37d": 1,
87
85
  "5587ec0cdb7b96e6": 1,
88
- "559b5241047cd753": 1,
89
86
  "562ebfb882d4aa8f": 1,
90
87
  "56b5df15ee61887d": 1,
91
88
  "56b7675e4ce57335": 1,
@@ -102,11 +99,12 @@
102
99
  "608e1dd27138ec6f": 1,
103
100
  "6098544c77ff17a2": 1,
104
101
  "60d0c58a01fc8746": 1,
102
+ "61ae5da6cc003836": 1,
105
103
  "61bcadcd8dc9f63e": 1,
106
- "63a3b672c8d8b50b": 1,
107
104
  "63ff613b1780c67a": 1,
108
105
  "642cc89ed49f8a21": 1,
109
106
  "6479c3604c153ec4": 1,
107
+ "6699064d6e8e09ba": 1,
110
108
  "66fb97eebd7ff0dc": 1,
111
109
  "67a95e428b8fa31c": 1,
112
110
  "6880d620e0e67229": 1,
@@ -115,10 +113,13 @@
115
113
  "6af3c307939ef75e": 1,
116
114
  "6b35b48512756192": 1,
117
115
  "6cd00d6b6a039022": 1,
116
+ "6da3fe1d15b66ae0": 1,
118
117
  "6e2457cd3e1f69e7": 1,
118
+ "6f30e86d1407e88c": 1,
119
119
  "6fa62e2642cc73c3": 1,
120
120
  "7009c5e9bd61dbe8": 1,
121
121
  "709348ea44394b52": 1,
122
+ "72705e6a2212d7b2": 1,
122
123
  "731fbc5d4188839a": 1,
123
124
  "73dbf56d17ab79ed": 1,
124
125
  "760d9925f6e3ca85": 1,
@@ -141,28 +142,22 @@
141
142
  "8678919f1350f154": 1,
142
143
  "86aae81b1aa0d21b": 1,
143
144
  "87484a2744e09748": 1,
144
- "87514cf1299c9c0e": 1,
145
145
  "876d2c8edfc8c40a": 1,
146
146
  "8b013ac20e436d76": 1,
147
147
  "8c2b1408886c1db1": 1,
148
- "8cb9a984bf19a062": 1,
149
148
  "8ec18cdbd4a96518": 1,
150
- "8ecf2e554a04d2ef": 1,
151
149
  "91049efadc65169b": 1,
152
150
  "9134185980de8a17": 1,
153
151
  "916833bafca4fa63": 1,
154
152
  "91a538e4a0c28f25": 1,
155
- "929fba5228e9fdac": 1,
156
153
  "92ddafa7e65be7eb": 1,
157
154
  "94821a4d1b745dfd": 1,
158
- "94d89888ac9da9a9": 1,
159
155
  "97c5a112fdde7be8": 1,
160
156
  "98cc22079feaa004": 1,
161
157
  "990d87f21678fa96": 1,
162
158
  "9a72b1c2f22156ca": 1,
163
159
  "9b6ef4df04562546": 1,
164
160
  "9c055dfd90c369bf": 1,
165
- "9cb19f1b972e811e": 1,
166
161
  "9ee57dcd5e7a6691": 1,
167
162
  "9fbb8296259dedbe": 1,
168
163
  "a0183465782b0b15": 1,
@@ -175,25 +170,26 @@
175
170
  "a78d76908162699e": 1,
176
171
  "a9bd4ffef6da5faf": 1,
177
172
  "a9d71c2e50f1b832": 1,
178
- "abe5a58f2b2cc593": 1,
179
173
  "acc95d694eb2f581": 1,
180
174
  "ad4469f2e8131535": 1,
181
175
  "ad4cfa76daa1b6fc": 1,
176
+ "ad50b48938af8aef": 1,
182
177
  "adae1329361a81a1": 1,
183
178
  "ae477ba64bafba3f": 1,
184
179
  "aee1e5c65f783848": 1,
185
180
  "aee45e8b58c2d87f": 1,
181
+ "af702b20ebd45ef2": 1,
186
182
  "af9d6a2f4eba146e": 1,
187
183
  "b010686af23efa90": 1,
188
184
  "b2a0686231220c8a": 1,
189
185
  "b35ff46c365dcd18": 1,
190
186
  "b41581af204af40e": 1,
191
- "b62f3449cde7991d": 1,
192
187
  "b71fa5250da28739": 1,
193
188
  "b740ecac685a15fe": 1,
194
189
  "b8c2d21f2a26f77d": 1,
195
190
  "bb269582b94ed42d": 1,
196
191
  "bb3e6d0f7e7edccd": 1,
192
+ "bc9115be3a462930": 1,
197
193
  "bccf0d5cc57c08df": 1,
198
194
  "bd0ae42c13d5b4f9": 1,
199
195
  "bd6c62a6e1c6a071": 1,
@@ -209,6 +205,7 @@
209
205
  "c415ffcd004d6518": 1,
210
206
  "c461a83b51eb7740": 1,
211
207
  "c9cf3c28bed33a5b": 1,
208
+ "cab590a1d04b5a05": 1,
212
209
  "cbd444e9a6a9072e": 1,
213
210
  "cd49f39b43d14635": 1,
214
211
  "cebff7d1cc87f646": 1,
@@ -221,7 +218,6 @@
221
218
  "d1a4a8e6a523b752": 1,
222
219
  "d1e5c2961267399a": 1,
223
220
  "d2ed3d02dd6a2d8c": 1,
224
- "d4eec99f58906a63": 1,
225
221
  "d6157a86fea41907": 1,
226
222
  "d6fa9884eee815f0": 1,
227
223
  "d86bafb915488255": 1,
@@ -239,7 +235,6 @@
239
235
  "ea1195048ded896c": 1,
240
236
  "ea88711c5deb1fe5": 1,
241
237
  "eb9a70c2a21094ff": 1,
242
- "ebd08637a2d7bcf3": 1,
243
238
  "ebe03c5f8c2f5147": 1,
244
239
  "ee19c8b9b332ee98": 1,
245
240
  "eeca0ccf8d821f21": 1,
@@ -253,11 +248,11 @@
253
248
  "f4bfa7e503443cd1": 1,
254
249
  "f6678d6748e9c902": 1,
255
250
  "f67a07a611f39cb2": 1,
251
+ "f7b1f9d699bcd580": 1,
256
252
  "f911821aa7dda47f": 1,
257
253
  "f97bf515bfd95829": 1,
258
254
  "fae844842fba705b": 1,
259
255
  "faf76ec43b2dbbaa": 1,
260
- "fb3a2e9ace145907": 1,
261
256
  "fc6ed7d760c98f51": 1,
262
257
  "fccfd8b004c12dd4": 1,
263
258
  "fe859df9f97c75d4": 1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4401d63: Record traces continuously across navigation and live state, and export them as
8
+ Links Notation.
9
+
10
+ `startTrace({mode: 'continuous'})` now keeps recording when the page navigates,
11
+ changes route or creates a frame: the observers are reinstalled from an init
12
+ script that runs before the page's own code, so nothing that happens during a
13
+ load is lost. What a person changes but the DOM does not show - typing,
14
+ checking, selecting, focus and scroll - is recorded as semantic live-state
15
+ records and applied on replay, child-list records carry the position a node was
16
+ inserted at or removed from, and every record carries stable trace, browser
17
+ context, page, navigation, frame and action identities. `initialCheckpoint` (on
18
+ by default) captures the page before the first action so the first interval has
19
+ a base to replay onto, and the viewer says what it is: a diagnostic replay of
20
+ what was recorded, not a re-execution of the session.
21
+
22
+ `writeTraceLinks(trace, './run.lino', {include})` writes a portable Links
23
+ Notation view of a bundle, and `startTrace({links: {output}})` writes the same
24
+ view incrementally as the run happens. One link per line: one per ordered
25
+ timeline event with its sequence, time, kind, owner ids, actor, action, target
26
+ and outcome; one per checkpoint naming its HTML, state and screenshot members by
27
+ their path inside the bundle; one per control that changed between two
28
+ checkpoints. Dropped and partial records are explicit, binary content is never
29
+ duplicated, and redaction is whatever the bundle already decided. The JSON
30
+ bundle stays authoritative - this is an adapter, not a replacement.
31
+
32
+ A managed download whose CDP completion arrives before the file does is no
33
+ longer reported as a missing file: the staged bytes are waited for, and a
34
+ timeout or a genuinely missing file is still a failure rather than a path that
35
+ does not exist.
36
+
37
+ ## 0.18.0
38
+
39
+ ### Minor Changes
40
+
41
+ - 0915dd8: Add managed downloads, portable traces and truthful click results.
42
+
43
+ `downloads` is now accepted by `launchBrowser()`, `connectBrowser()` and
44
+ `launchRealBrowser()`, and by `commander.configureDownloads()` on a browser you
45
+ already have. The manager owns the file rather than the page: `capture()` starts
46
+ listening before the action runs so a fast download cannot be missed, a caller
47
+ can choose the saved name and have a bare UUID given the extension its content
48
+ proves, and the file is still there after the context and the browser are gone.
49
+ Failed and cancelled downloads report the failure instead of a path, and
50
+ `browser-commander/tests` keeps captured files in the run's artifact directory.
51
+
52
+ `startTrace()` records a session into a schema-versioned bundle - an ordered
53
+ NDJSON timeline, named checkpoints with full HTML, live control state and frame
54
+ metadata, and the DOM mutation batches between them - which `readTrace()`,
55
+ `diffControlState()` and the offline `writeTraceViewer()` read back with scripts
56
+ and network disabled. Redaction runs before anything is written, a truncated or
57
+ interrupted run still produces a readable partial trace, and
58
+ `browser-commander/tests` supports `trace: 'retain-on-failure'` in place of its
59
+ own artifact mechanism.
60
+
61
+ `clickElement()` now reports what was observed: `status` is `succeeded`,
62
+ `failed`, `timed_out`, `interrupted` or `unverified`, `effect` is `confirmed`,
63
+ `not-observed` or `contradicted`, and `evidence` carries the reason for both.
64
+ The `clicked` and `verified` booleans remain and are derived conservatively, so
65
+ a click that changed nothing is no longer reported as verified. `waitForReady()`
66
+ composes URL, network, DOM, image and custom checks under one deadline and
67
+ returns the same per-check evidence. `noAutoScroll` is deprecated in favour of
68
+ the `scroll` axis; `scroll: 'none'` now fails with a `ScrollConstraintError` on
69
+ engines that cannot click without scrolling instead of scrolling anyway.
70
+
3
71
  ## 0.17.2
4
72
 
5
73
  ### Patch Changes
package/README.md CHANGED
@@ -599,6 +599,138 @@ 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. In `continuous`
661
+ mode the observers are reinstalled from an init script before the page's own
662
+ code runs, so a trace keeps recording across navigations, SPA route changes and
663
+ same-document updates, and it records what a person cannot see in the DOM -
664
+ typing, checking, selecting, focus and scroll - as semantic live-state records
665
+ (issue #93). `initialCheckpoint` (on by default) captures the page as it was
666
+ before the first action, so the first interval has a base to replay onto. The
667
+ viewer seeks, diffs and replays the captured DOM with scripts and network
668
+ disabled; it is a diagnostic replay of what was recorded, not a pixel-accurate
669
+ re-execution of the session.
670
+
671
+ #### Links Notation Export
672
+
673
+ The bundle stays authoritative, and `writeTraceLinks()` writes a second,
674
+ portable view of it for tools that keep semantic, actor-aware histories - audit
675
+ graphs, agent memory, cross-run diffing (issue #94):
676
+
677
+ ```javascript
678
+ import { readTrace, writeTraceLinks } from 'browser-commander';
679
+
680
+ const trace = await readTrace('./run.bc-trace');
681
+ await writeTraceLinks(trace, './run.lino', {
682
+ include: ['timeline', 'checkpoints', 'control-diffs'],
683
+ });
684
+ ```
685
+
686
+ A long-lived agent can have the same export written as the run happens, so a
687
+ process that is killed still leaves everything it had recorded:
688
+
689
+ ```javascript
690
+ const trace = await commander.startTrace({
691
+ output: './run.bc-trace',
692
+ links: { output: './run.lino' },
693
+ });
694
+ // trace.links === './run.lino'
695
+ ```
696
+
697
+ One link per line: one `(timeline: ...)` link per ordered event carrying its
698
+ sequence, time, kind, owner ids, actor, action, target and outcome; one
699
+ `(checkpoint: ...)` link naming its HTML, state and screenshot members by their
700
+ path inside the bundle; one `(control-diff: ...)` link per control that changed
701
+ between two checkpoints, with `path`, `before`, `after` and who changed it.
702
+ Dropped and partial records are written as such rather than left out, no binary
703
+ content is ever copied into the export, and redaction is whatever the bundle
704
+ already decided - the export only reads what was written there. The
705
+ representation is pinned by a golden test against
706
+ [`links-notation`](https://www.npmjs.com/package/links-notation) 0.20, whose
707
+ Python and Rust implementations read the same file;
708
+ `experiments/trace-links-export.mjs` records a real run and parses its export
709
+ from Python to prove it.
710
+
711
+ ### Truthful Click Results
712
+
713
+ `clickElement()` reports what was observed, not what was attempted:
714
+
715
+ ```javascript
716
+ const result = await clickElement({
717
+ page,
718
+ engine,
719
+ locatorOrElement: '#submit',
720
+ });
721
+
722
+ result.status; // 'succeeded' | 'failed' | 'timed_out' | 'interrupted' | 'unverified'
723
+ result.effect; // 'confirmed' | 'not-observed' | 'contradicted'
724
+ result.evidence; // why status and effect say what they say
725
+ result.clicked; // still here: whether the click reached the element
726
+ result.verified; // still here, now derived from effect === 'confirmed'
727
+ ```
728
+
729
+ The `scroll` axis (`auto`, `preserve`, `none`) replaces the deprecated
730
+ `noAutoScroll` flag. `scroll: 'none'` never scrolls: on an engine that cannot
731
+ deliver a click without scrolling it throws `ScrollConstraintError` naming the
732
+ alternatives, rather than scrolling the page and reporting success.
733
+
602
734
  ### commander.destroy()
603
735
 
604
736
  ```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.19.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",
@@ -65,6 +68,7 @@
65
68
  },
66
69
  "dependencies": {
67
70
  "better-sqlite3": "^12.11.1",
71
+ "links-notation": "^0.20.0",
68
72
  "log-lazy": "^1.0.4",
69
73
  "test-anywhere": "^0.9.1"
70
74
  },
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).