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
@@ -0,0 +1,476 @@
1
+ /**
2
+ * Managed, persistent downloads (issue #88).
3
+ *
4
+ * The manager owns one lifecycle for every download the browser performs -
5
+ * automated or started by a person - and guarantees three things a caller
6
+ * cannot get from an engine event alone:
7
+ *
8
+ * - the file survives the page, context and browser that produced it;
9
+ * - a download is saved once and reported once, even when a global listener
10
+ * and an awaited `capture()` both see it;
11
+ * - a file under its final name is complete and has passed validation.
12
+ */
13
+
14
+ import { EventEmitter } from 'node:events';
15
+ import fs from 'node:fs/promises';
16
+
17
+ import { createDeadline } from '../core/readiness.js';
18
+ import {
19
+ prepareDownloadDirectory,
20
+ resolveDownloadDirectory,
21
+ } from './destination.js';
22
+ import {
23
+ attachCdpSource,
24
+ attachPlaywrightSource,
25
+ classifyFailure,
26
+ DOWNLOAD_FAILURE,
27
+ openBrowserCdpSession,
28
+ } from './sources.js';
29
+ import { DOWNLOAD_CONFLICT, saveDownload } from './store.js';
30
+
31
+ /** Lifecycle events every download passes through. */
32
+ export const DOWNLOAD_EVENT = Object.freeze({
33
+ STARTED: 'started',
34
+ COMPLETED: 'completed',
35
+ FAILED: 'failed',
36
+ CANCELLED: 'cancelled',
37
+ });
38
+
39
+ /** How long `capture()` waits for a download by default. */
40
+ export const DEFAULT_CAPTURE_TIMEOUT = 30000;
41
+
42
+ /** Monotonic part of a download ID, so IDs are stable and ordered. */
43
+ let downloadCounter = 0;
44
+
45
+ /**
46
+ * Build the identifier a download is reported under.
47
+ *
48
+ * @returns {string} Stable download ID
49
+ */
50
+ function nextDownloadId() {
51
+ downloadCounter += 1;
52
+ return `dl-${String(downloadCounter).padStart(6, '0')}`;
53
+ }
54
+
55
+ /**
56
+ * Find the object Playwright emits its `download` event on.
57
+ *
58
+ * A download is a *context*-level event. `launchBrowser()` hands back a
59
+ * persistent context, so the browser handle works there by accident; a
60
+ * `Browser` returned by `connectBrowser()` never emits `download` at all.
61
+ * Taking the page's context is what makes an attached browser behave exactly
62
+ * like a launched one.
63
+ *
64
+ * @param {Object} options - Handles the caller supplied
65
+ * @param {Object} [options.browser] - Browser or persistent context
66
+ * @param {Object} [options.page] - A page belonging to the browser
67
+ * @returns {Object|undefined} The context downloads are observed on
68
+ */
69
+ function playwrightDownloadContext({ browser, page }) {
70
+ const fromPage =
71
+ typeof page?.context === 'function' ? page.context() : undefined;
72
+ return fromPage ?? browser;
73
+ }
74
+
75
+ /**
76
+ * Create the download manager for a browser.
77
+ *
78
+ * @param {Object} options - Manager options
79
+ * @param {string} options.engine - 'playwright' or 'puppeteer'
80
+ * @param {Object} [options.browser] - Browser or persistent context
81
+ * @param {Object} [options.context] - Playwright browser context
82
+ * @param {Object} [options.page] - A page belonging to the browser
83
+ * @param {string} [options.directory] - Absolute path, 'user-downloads' or 'temporary'
84
+ * @param {boolean} [options.persist=true] - Keep files after the browser closes
85
+ * @param {string} [options.conflict='rename'] - 'rename', 'overwrite' or 'error'
86
+ * @param {Function} [options.filename] - Naming callback for every download
87
+ * @param {Function} [options.validate] - Validation for every download
88
+ * @param {number} [options.stagingTimeout] - How long a completed download has
89
+ * to become readable on disk before it is reported as failed (issue #92)
90
+ * @param {number} [options.stagingPollInterval] - Milliseconds between readings
91
+ * @param {Object} [options.log] - Logger
92
+ * @returns {Promise<Object>} Download manager
93
+ */
94
+ export async function createDownloadManager(options = {}) {
95
+ const {
96
+ engine = 'playwright',
97
+ browser,
98
+ context: explicitContext,
99
+ page,
100
+ directory,
101
+ persist = true,
102
+ conflict = DOWNLOAD_CONFLICT.RENAME,
103
+ filename,
104
+ validate,
105
+ stagingTimeout,
106
+ stagingPollInterval,
107
+ log,
108
+ } = options;
109
+
110
+ const context =
111
+ explicitContext ??
112
+ (engine === 'playwright'
113
+ ? playwrightDownloadContext({ browser, page })
114
+ : browser);
115
+
116
+ // Resolved and probed before a single download can start: a permission
117
+ // problem found afterwards looks like a file that never arrived.
118
+ const root = await prepareDownloadDirectory(
119
+ await resolveDownloadDirectory(directory)
120
+ );
121
+
122
+ const emitter = new EventEmitter();
123
+ const artifacts = [];
124
+ const pending = new Map();
125
+ const waiters = new Set();
126
+ const seen = new Set();
127
+ const published = new Set();
128
+ const inFlight = new Set();
129
+
130
+ /**
131
+ * Hand a settled artifact to every waiter armed before it started.
132
+ *
133
+ * @param {Object} artifact - Settled artifact
134
+ * @returns {void}
135
+ */
136
+ const settleWaiters = (artifact) => {
137
+ for (const waiter of [...waiters]) {
138
+ if (waiter.accepts(artifact)) {
139
+ waiters.delete(waiter);
140
+ waiter.settle(artifact);
141
+ }
142
+ }
143
+ };
144
+
145
+ /**
146
+ * Publish an artifact under its lifecycle event exactly once.
147
+ *
148
+ * @param {Object} artifact - Artifact to publish
149
+ * @param {string} event - Lifecycle event name
150
+ * @returns {void}
151
+ */
152
+ const publish = (artifact, event) => {
153
+ // A download reaches its end once. Both a global listener and an awaited
154
+ // `capture()` read from this same record, so publishing twice would mean
155
+ // one file reported as two.
156
+ if (published.has(artifact)) {
157
+ return;
158
+ }
159
+ published.add(artifact);
160
+ artifact.state = event;
161
+ emitter.emit(event, artifact);
162
+ settleWaiters(artifact);
163
+ };
164
+
165
+ const sink = {
166
+ /**
167
+ * Record a download the engine has just announced.
168
+ *
169
+ * @param {Object} start - Engine-reported start details
170
+ * @returns {Object} Artifact record
171
+ */
172
+ started(start) {
173
+ // One engine download can be announced twice - a context listener and a
174
+ // page listener see the same object - so the engine's own handle is the
175
+ // identity, not the order events arrived in.
176
+ if (seen.has(start.engineHandle)) {
177
+ return pending.get(start.engineHandle);
178
+ }
179
+ seen.add(start.engineHandle);
180
+
181
+ const artifact = {
182
+ id: nextDownloadId(),
183
+ url: start.url,
184
+ suggestedFilename: start.suggestedFilename,
185
+ state: DOWNLOAD_EVENT.STARTED,
186
+ startedAt: new Date().toISOString(),
187
+ completedAt: null,
188
+ path: null,
189
+ mimeType: start.mimeType ?? null,
190
+ bytes: null,
191
+ checksum: null,
192
+ failure: null,
193
+ };
194
+
195
+ pending.set(start.engineHandle, artifact);
196
+ artifacts.push(artifact);
197
+ emitter.emit(DOWNLOAD_EVENT.STARTED, artifact);
198
+ return artifact;
199
+ },
200
+
201
+ /**
202
+ * Register work a download is not finished without.
203
+ *
204
+ * Engine events are fire-and-forget, so anything a source starts in one -
205
+ * saving the bytes, or waiting for them to land on disk at all - has to be
206
+ * registered here: otherwise `dispose()` could return while a download is
207
+ * still in flight and leave a `.partial` file as its only trace.
208
+ *
209
+ * @param {Promise} work - Work to wait for in `idle()` and `dispose()`
210
+ * @returns {Promise<void>} The same work, tracked
211
+ */
212
+ track(work) {
213
+ inFlight.add(work);
214
+ return work.finally(() => inFlight.delete(work));
215
+ },
216
+
217
+ /**
218
+ * Save a download whose bytes the engine has finished writing.
219
+ *
220
+ * @param {Object} artifact - Artifact record
221
+ * @param {Object} source - `{path}` or `{stream}` to read the bytes from
222
+ * @returns {Promise<void>} Resolves once the artifact has been published
223
+ */
224
+ finished(artifact, source) {
225
+ return sink.track(save(artifact, source));
226
+ },
227
+
228
+ /**
229
+ * Record a download that ended without a file.
230
+ *
231
+ * @param {Object} artifact - Artifact record
232
+ * @param {string} kind - {@link DOWNLOAD_FAILURE} classification
233
+ * @param {string} reason - Engine-reported reason, kept verbatim
234
+ * @returns {void}
235
+ */
236
+ failed(artifact, kind, reason) {
237
+ if (!artifact) {
238
+ return;
239
+ }
240
+ artifact.failure = reason;
241
+ publish(
242
+ artifact,
243
+ kind === DOWNLOAD_FAILURE.CANCELLED
244
+ ? DOWNLOAD_EVENT.CANCELLED
245
+ : DOWNLOAD_EVENT.FAILED
246
+ );
247
+ },
248
+ };
249
+
250
+ /**
251
+ * Place a finished download's bytes under their final name.
252
+ *
253
+ * @param {Object} artifact - Artifact record
254
+ * @param {Object} source - `{path}` or `{stream}` to read the bytes from
255
+ * @returns {Promise<void>}
256
+ */
257
+ async function save(artifact, source) {
258
+ if (!artifact || artifact.state !== DOWNLOAD_EVENT.STARTED) {
259
+ return;
260
+ }
261
+
262
+ try {
263
+ const saved = await saveDownload({
264
+ root,
265
+ source,
266
+ suggestedFilename: artifact.suggestedFilename,
267
+ mimeType: artifact.mimeType,
268
+ conflict: artifact.conflict ?? conflict,
269
+ filename: artifact.filename ?? filename,
270
+ validate: artifact.validate ?? validate,
271
+ });
272
+
273
+ Object.assign(artifact, saved, {
274
+ completedAt: new Date().toISOString(),
275
+ });
276
+ publish(artifact, DOWNLOAD_EVENT.COMPLETED);
277
+ } catch (error) {
278
+ artifact.failure = error.message;
279
+ publish(artifact, DOWNLOAD_EVENT.FAILED);
280
+ log?.warn?.(`download ${artifact.id} could not be saved`);
281
+ } finally {
282
+ if (source.removeSource && source.path) {
283
+ await fs.rm(source.path, { force: true });
284
+ }
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Wait until every download the manager has seen has been placed.
290
+ *
291
+ * @returns {Promise<void>}
292
+ */
293
+ const idle = async () => {
294
+ while (inFlight.size > 0) {
295
+ await Promise.all([...inFlight]);
296
+ }
297
+ };
298
+
299
+ const attached = [];
300
+
301
+ // CDP is what makes a download a *person* started observable, and it is the
302
+ // only source Puppeteer has, so it is tried first. It is also exclusive:
303
+ // `Browser.setDownloadBehavior` takes the bytes away from Playwright's own
304
+ // download handling, so attaching both sources would mean one download
305
+ // reported twice and a Playwright `path()` pointing at a file that was
306
+ // never written.
307
+ let cdpSession;
308
+ try {
309
+ cdpSession = await openBrowserCdpSession({ engine, browser, page });
310
+ if (cdpSession) {
311
+ attached.push(
312
+ await attachCdpSource({
313
+ session: cdpSession,
314
+ root,
315
+ sink,
316
+ stagingTimeout,
317
+ stagingPollInterval,
318
+ })
319
+ );
320
+ }
321
+ } catch (error) {
322
+ cdpSession = undefined;
323
+ log?.warn?.(`manual downloads are not observable: ${error.message}`);
324
+ }
325
+
326
+ // Without Browser-domain access - an attached browser that refuses it, or a
327
+ // context with no page to borrow a session from - Playwright's own event
328
+ // still covers every automated download.
329
+ if (attached.length === 0 && engine === 'playwright' && context?.on) {
330
+ attached.push(attachPlaywrightSource({ context, sink }));
331
+ }
332
+
333
+ if (attached.length === 0) {
334
+ log?.warn?.(
335
+ 'no download source could be attached: downloads will not be managed'
336
+ );
337
+ }
338
+
339
+ /**
340
+ * Wait for a download, optionally triggering it first.
341
+ *
342
+ * @param {Object} [captureOptions] - Capture options
343
+ * @param {Function} [captureOptions.action] - Action that triggers the download
344
+ * @param {Function|string} [captureOptions.filename] - Name for this download
345
+ * @param {number} [captureOptions.timeout] - Budget for the whole capture
346
+ * @param {Function} [captureOptions.validate] - Validation for this download
347
+ * @returns {Promise<Object>} The settled artifact
348
+ */
349
+ const capture = async (captureOptions = {}) => {
350
+ const {
351
+ action,
352
+ filename: captureFilename,
353
+ timeout = DEFAULT_CAPTURE_TIMEOUT,
354
+ validate: captureValidate,
355
+ } = captureOptions;
356
+
357
+ const deadline = createDeadline({ timeout });
358
+ const armedAt = artifacts.length;
359
+
360
+ let settle;
361
+ const settled = new Promise((resolve) => {
362
+ settle = resolve;
363
+ });
364
+
365
+ const waiter = {
366
+ /**
367
+ * Accept the first download that started after this waiter was armed.
368
+ *
369
+ * @param {Object} artifact - Settled artifact
370
+ * @returns {boolean} Whether this waiter claims it
371
+ */
372
+ accepts: (artifact) => artifacts.indexOf(artifact) >= armedAt,
373
+ settle,
374
+ };
375
+ waiters.add(waiter);
376
+
377
+ // Per-download naming and validation are attached to whichever download
378
+ // this waiter claims, so they cannot leak onto an unrelated one.
379
+ const claim = (artifact) => {
380
+ if (captureFilename !== undefined) {
381
+ artifact.filename =
382
+ typeof captureFilename === 'function'
383
+ ? captureFilename
384
+ : () => captureFilename;
385
+ }
386
+ if (captureValidate) {
387
+ artifact.validate = captureValidate;
388
+ }
389
+ };
390
+
391
+ const onStarted = (artifact) => {
392
+ if (waiter.accepts(artifact)) {
393
+ claim(artifact);
394
+ }
395
+ };
396
+ emitter.on(DOWNLOAD_EVENT.STARTED, onStarted);
397
+
398
+ // Both the waiter and the naming rules are armed before the action runs.
399
+ // A download can start, finish and be saved inside `action()` - attaching
400
+ // them afterwards would save the file under the wrong name and skip the
401
+ // caller's validation entirely.
402
+ try {
403
+ if (action) {
404
+ await action();
405
+ }
406
+ } catch (error) {
407
+ waiters.delete(waiter);
408
+ emitter.off(DOWNLOAD_EVENT.STARTED, onStarted);
409
+ throw error;
410
+ }
411
+
412
+ let timer;
413
+ const expiry = new Promise((_resolve, reject) => {
414
+ timer = setTimeout(
415
+ () =>
416
+ reject(
417
+ new Error(
418
+ `no download completed within ${timeout}ms of the triggering action`
419
+ )
420
+ ),
421
+ Math.max(1, deadline.remainingMs())
422
+ );
423
+ });
424
+
425
+ try {
426
+ const artifact = await Promise.race([settled, expiry]);
427
+ if (artifact.state !== DOWNLOAD_EVENT.COMPLETED) {
428
+ throw new Error(
429
+ `download ${artifact.id} ${artifact.state}: ${artifact.failure}`
430
+ );
431
+ }
432
+ return artifact;
433
+ } finally {
434
+ clearTimeout(timer);
435
+ waiters.delete(waiter);
436
+ emitter.off(DOWNLOAD_EVENT.STARTED, onStarted);
437
+ }
438
+ };
439
+
440
+ return {
441
+ directory: root,
442
+ persist,
443
+ conflict,
444
+ /**
445
+ * List every download seen in this session.
446
+ *
447
+ * @returns {Object[]} Artifacts in the order they started
448
+ */
449
+ list: () => [...artifacts],
450
+ capture,
451
+ idle,
452
+ on: (event, listener) => emitter.on(event, listener),
453
+ once: (event, listener) => emitter.once(event, listener),
454
+ off: (event, listener) => emitter.off(event, listener),
455
+ /**
456
+ * Stop observing downloads.
457
+ *
458
+ * Saved files are untouched: persistence is the point of the manager, so
459
+ * detaching must never be the thing that removes a user's file.
460
+ *
461
+ * @returns {Promise<void>}
462
+ */
463
+ dispose: async () => {
464
+ // A download still being written is finished first: detaching must not
465
+ // be the reason a file only ever exists under its `.partial` name.
466
+ await idle();
467
+ for (const handle of attached) {
468
+ await handle.detach();
469
+ }
470
+ await cdpSession?.detach?.().catch?.(() => {});
471
+ emitter.removeAllListeners();
472
+ },
473
+ };
474
+ }
475
+
476
+ export { classifyFailure, DOWNLOAD_CONFLICT, DOWNLOAD_FAILURE };