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
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Engine-specific download event sources (issue #88).
3
+ *
4
+ * Each source turns one engine's download notifications into the same three
5
+ * calls - `started`, `finished`, `failed` - so the manager above them does not
6
+ * know or care whether the bytes came from Playwright or from CDP, and a
7
+ * download a human started by hand is reported exactly like an automated one.
8
+ */
9
+
10
+ import path from 'node:path';
11
+ import fs from 'node:fs/promises';
12
+
13
+ import { ARTIFACT_DIRECTORY_MODE } from './destination.js';
14
+
15
+ /** Why a download ended without a file. */
16
+ export const DOWNLOAD_FAILURE = Object.freeze({
17
+ CANCELLED: 'cancelled',
18
+ FAILED: 'failed',
19
+ });
20
+
21
+ /** Directory the engine writes into before we place the file. */
22
+ export const STAGING_DIRECTORY = '.browser-commander-staging';
23
+
24
+ /**
25
+ * Classify an engine's failure text.
26
+ *
27
+ * "A download UI entry is not completion evidence" (issue #88): the engine's
28
+ * own words are the only thing that distinguishes a user cancelling a download
29
+ * from a network error, so they are preserved and classified, not flattened.
30
+ *
31
+ * @param {string} [reason] - Engine-reported reason
32
+ * @returns {string} One of {@link DOWNLOAD_FAILURE}
33
+ */
34
+ export function classifyFailure(reason) {
35
+ return /cancel/i.test(String(reason ?? ''))
36
+ ? DOWNLOAD_FAILURE.CANCELLED
37
+ : DOWNLOAD_FAILURE.FAILED;
38
+ }
39
+
40
+ /**
41
+ * Attach to Playwright's context-level download event.
42
+ *
43
+ * @param {Object} options - Source options
44
+ * @param {Object} options.context - Playwright browser context
45
+ * @param {Object} options.sink - `{started, finished, failed}` callbacks
46
+ * @returns {Object} Handle with `detach()`
47
+ */
48
+ export function attachPlaywrightSource({ context, sink }) {
49
+ const onDownload = async (download) => {
50
+ const record = sink.started({
51
+ engineHandle: download,
52
+ url: download.url(),
53
+ suggestedFilename: download.suggestedFilename(),
54
+ page: download.page?.(),
55
+ });
56
+
57
+ try {
58
+ const failure = await download.failure();
59
+ if (failure) {
60
+ sink.failed(record, classifyFailure(failure), failure);
61
+ return;
62
+ }
63
+
64
+ // `path()` resolves only once the bytes are on disk, which is the
65
+ // engine's own evidence that the download completed.
66
+ const enginePath = await download.path();
67
+ sink.finished(record, { path: enginePath });
68
+ } catch (error) {
69
+ sink.failed(record, classifyFailure(error.message), error.message);
70
+ }
71
+ };
72
+
73
+ context.on('download', onDownload);
74
+ return {
75
+ detach: () => context.off('download', onDownload),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Point Chromium at a staging directory and report every download it starts.
81
+ *
82
+ * This is the only source that sees a download a *person* started, because
83
+ * `Browser.setDownloadBehavior` is browser-wide rather than per-automation.
84
+ *
85
+ * @param {Object} options - Source options
86
+ * @param {Object} options.session - CDP session with Browser domain access
87
+ * @param {string} options.root - Managed download directory
88
+ * @param {Object} options.sink - `{started, finished, failed}` callbacks
89
+ * @returns {Promise<Object>} Handle with `detach()` and `stagingDirectory`
90
+ */
91
+ export async function attachCdpSource({ session, root, sink }) {
92
+ const stagingDirectory = path.join(root, STAGING_DIRECTORY);
93
+ await fs.mkdir(stagingDirectory, {
94
+ recursive: true,
95
+ mode: ARTIFACT_DIRECTORY_MODE,
96
+ });
97
+
98
+ // `allowAndName` writes each file under its GUID, so two downloads that
99
+ // suggest the same name cannot overwrite each other before we have placed
100
+ // them, and the GUID is the identity we deduplicate on.
101
+ await session.send('Browser.setDownloadBehavior', {
102
+ behavior: 'allowAndName',
103
+ downloadPath: stagingDirectory,
104
+ eventsEnabled: true,
105
+ });
106
+
107
+ const byGuid = new Map();
108
+
109
+ const onWillBegin = ({ guid, url, suggestedFilename }) => {
110
+ byGuid.set(
111
+ guid,
112
+ sink.started({
113
+ engineHandle: guid,
114
+ url,
115
+ suggestedFilename,
116
+ })
117
+ );
118
+ };
119
+
120
+ const onProgress = ({ guid, state }) => {
121
+ const record = byGuid.get(guid);
122
+ if (!record || state === 'inProgress') {
123
+ return;
124
+ }
125
+
126
+ byGuid.delete(guid);
127
+ if (state === 'completed') {
128
+ sink.finished(record, {
129
+ path: path.join(stagingDirectory, guid),
130
+ removeSource: true,
131
+ });
132
+ return;
133
+ }
134
+ sink.failed(
135
+ record,
136
+ DOWNLOAD_FAILURE.CANCELLED,
137
+ `download ${state} by the browser`
138
+ );
139
+ };
140
+
141
+ session.on('Browser.downloadWillBegin', onWillBegin);
142
+ session.on('Browser.downloadProgress', onProgress);
143
+
144
+ return {
145
+ stagingDirectory,
146
+ detach: () => {
147
+ session.off?.('Browser.downloadWillBegin', onWillBegin);
148
+ session.off?.('Browser.downloadProgress', onProgress);
149
+ },
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Open a CDP session that can drive the Browser domain.
155
+ *
156
+ * Both engines can open a session that is not tied to a single page, which is
157
+ * what the Browser domain needs: one session then covers every page and every
158
+ * download no page started.
159
+ *
160
+ * @param {Object} options - Session options
161
+ * @param {string} options.engine - Engine name
162
+ * @param {Object} [options.browser] - Browser or persistent context
163
+ * @param {Object} [options.page] - Page to borrow a session from
164
+ * @returns {Promise<Object>|undefined} CDP session, or undefined when unavailable
165
+ */
166
+ export function openBrowserCdpSession({ engine, browser, page }) {
167
+ if (engine === 'puppeteer') {
168
+ const target = browser?.target?.();
169
+ if (target?.createCDPSession) {
170
+ return target.createCDPSession();
171
+ }
172
+ return page?.target?.().createCDPSession?.();
173
+ }
174
+
175
+ // A page-scoped session only hears about the downloads that page started,
176
+ // so a browser-wide session is what makes a download a *person* began - in a
177
+ // tab the automation never opened - observable at all.
178
+ const browserHandle = browser?.browser?.() ?? browser;
179
+ if (browserHandle?.newBrowserCDPSession) {
180
+ return browserHandle.newBrowserCDPSession();
181
+ }
182
+
183
+ const context = browser?.browserContext?.() ?? browser;
184
+ if (page && context?.newCDPSession) {
185
+ return context.newCDPSession(page);
186
+ }
187
+ return undefined;
188
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Writing a download into the managed directory (issue #88).
3
+ *
4
+ * The rule the whole module exists to keep: a file that appears under its
5
+ * final name is complete and has passed validation. Everything else lives
6
+ * under a `.partial` name and is removed.
7
+ */
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import { createReadStream, createWriteStream } from 'node:fs';
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { pipeline } from 'node:stream/promises';
14
+
15
+ import { ARTIFACT_DIRECTORY_MODE, ARTIFACT_FILE_MODE } from './destination.js';
16
+ import {
17
+ renamedCandidate,
18
+ resolveInsideRoot,
19
+ sanitizeDownloadName,
20
+ withExtension,
21
+ } from './naming.js';
22
+
23
+ /** How a name that is already taken is resolved. */
24
+ export const DOWNLOAD_CONFLICT = Object.freeze({
25
+ RENAME: 'rename',
26
+ OVERWRITE: 'overwrite',
27
+ ERROR: 'error',
28
+ });
29
+
30
+ /** How many bytes are kept to sniff a format from. */
31
+ const MAGIC_BYTES = 8;
32
+
33
+ /** Highest rename attempt before giving up rather than looping forever. */
34
+ const MAX_RENAME_ATTEMPTS = 1000;
35
+
36
+ /**
37
+ * Open the download's bytes as a readable stream.
38
+ *
39
+ * @param {Object} source - Either `{stream}` or `{path}`
40
+ * @returns {Object} Readable stream
41
+ */
42
+ function openSource(source) {
43
+ if (source.stream) {
44
+ return source.stream;
45
+ }
46
+ if (source.path) {
47
+ return createReadStream(source.path);
48
+ }
49
+ throw new TypeError('a download source needs either a stream or a path');
50
+ }
51
+
52
+ /**
53
+ * Stream the download into a partial file, hashing it on the way through.
54
+ *
55
+ * Hashing during the copy means the bytes are read once: reading the file a
56
+ * second time to checksum it would double the I/O and leave a window where the
57
+ * file could change between the two reads.
58
+ *
59
+ * @param {Object} options - Copy options
60
+ * @param {Object} options.source - `{stream}` or `{path}` to read from
61
+ * @param {string} options.partialPath - Where the bytes are written
62
+ * @returns {Promise<{bytes: number, checksum: string, head: Buffer}>} What was written
63
+ */
64
+ async function copyToPartial({ source, partialPath }) {
65
+ const hash = createHash('sha256');
66
+ let bytes = 0;
67
+ let head = Buffer.alloc(0);
68
+
69
+ const readable = openSource(source);
70
+ const writable = createWriteStream(partialPath, { mode: ARTIFACT_FILE_MODE });
71
+
72
+ await pipeline(
73
+ readable,
74
+ async function* (chunks) {
75
+ for await (const chunk of chunks) {
76
+ hash.update(chunk);
77
+ bytes += chunk.length;
78
+ if (head.length < MAGIC_BYTES) {
79
+ head = Buffer.concat([head, chunk.subarray(0, MAGIC_BYTES)]).subarray(
80
+ 0,
81
+ MAGIC_BYTES
82
+ );
83
+ }
84
+ yield chunk;
85
+ }
86
+ },
87
+ writable
88
+ );
89
+
90
+ return { bytes, checksum: hash.digest('hex'), head };
91
+ }
92
+
93
+ /**
94
+ * Report whether a path already exists.
95
+ *
96
+ * @param {string} candidate - Absolute path
97
+ * @returns {Promise<boolean>} Whether something is there
98
+ */
99
+ async function exists(candidate) {
100
+ try {
101
+ await fs.access(candidate);
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Choose the final path for a completed download.
110
+ *
111
+ * @param {Object} options - Placement options
112
+ * @param {string} options.root - Absolute download directory
113
+ * @param {string} options.name - Sanitized file name
114
+ * @param {string} options.conflict - Conflict policy
115
+ * @returns {Promise<string>} Absolute final path
116
+ * @throws {Error} When the name is taken and the policy says to fail
117
+ */
118
+ export async function resolveFinalPath({ root, name, conflict }) {
119
+ if (conflict === DOWNLOAD_CONFLICT.OVERWRITE) {
120
+ return resolveInsideRoot(root, name);
121
+ }
122
+
123
+ for (let attempt = 0; attempt < MAX_RENAME_ATTEMPTS; attempt += 1) {
124
+ const candidate = resolveInsideRoot(root, renamedCandidate(name, attempt));
125
+ if (!(await exists(candidate))) {
126
+ return candidate;
127
+ }
128
+ if (conflict === DOWNLOAD_CONFLICT.ERROR) {
129
+ throw new Error(
130
+ `refusing to replace ${candidate}: downloads.conflict is 'error'`
131
+ );
132
+ }
133
+ }
134
+
135
+ throw new Error(
136
+ `could not find a free name for "${name}" after ${MAX_RENAME_ATTEMPTS} attempts`
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Save a download into the managed directory.
142
+ *
143
+ * @param {Object} options - Save options
144
+ * @param {string} options.root - Absolute download directory
145
+ * @param {Object} options.source - `{stream}` or `{path}` to read from
146
+ * @param {string} options.suggestedFilename - Name the page suggested
147
+ * @param {string} [options.mimeType] - MIME type declared by the server
148
+ * @param {string} [options.conflict] - Conflict policy
149
+ * @param {Function} [options.filename] - Caller naming callback
150
+ * @param {Function} [options.validate] - Validation run before the file is
151
+ * published; throwing or returning `false` rejects the download
152
+ * @returns {Promise<{path: string, bytes: number, checksum: string}>} What was saved
153
+ */
154
+ export async function saveDownload({
155
+ root,
156
+ source,
157
+ suggestedFilename,
158
+ mimeType,
159
+ conflict = DOWNLOAD_CONFLICT.RENAME,
160
+ filename,
161
+ validate,
162
+ }) {
163
+ await fs.mkdir(root, { recursive: true, mode: ARTIFACT_DIRECTORY_MODE });
164
+
165
+ // A caller callback runs *before* sanitization, never instead of it: it is a
166
+ // naming preference, not a grant of write access outside the root.
167
+ const chosen = filename
168
+ ? await filename({ suggestedFilename, mimeType })
169
+ : suggestedFilename;
170
+ const safeName = sanitizeDownloadName(chosen ?? suggestedFilename);
171
+
172
+ const partialPath = resolveInsideRoot(
173
+ root,
174
+ `${safeName}.${process.pid}.${Date.now()}.partial`
175
+ );
176
+
177
+ let written;
178
+ try {
179
+ written = await copyToPartial({ source, partialPath });
180
+
181
+ if (validate) {
182
+ // Validation sees the partial file, so a rejected download never exists
183
+ // under the name a caller would pick it up by.
184
+ const verdict = await validate({
185
+ path: partialPath,
186
+ bytes: written.bytes,
187
+ checksum: written.checksum,
188
+ mimeType,
189
+ suggestedFilename,
190
+ });
191
+ // A predicate that returns `false` rejects just as loudly as one that
192
+ // throws; anything else (including `undefined`) accepts, so a validator
193
+ // written only for its side effects still works.
194
+ if (verdict === false) {
195
+ throw new Error(
196
+ `"${safeName}" was rejected by the caller's validation`
197
+ );
198
+ }
199
+ }
200
+ } catch (error) {
201
+ await fs.rm(partialPath, { force: true });
202
+ throw error;
203
+ }
204
+
205
+ const finalName = withExtension({
206
+ name: safeName,
207
+ mimeType,
208
+ head: written.head,
209
+ });
210
+
211
+ try {
212
+ const finalPath = await resolveFinalPath({
213
+ root,
214
+ name: finalName,
215
+ conflict,
216
+ });
217
+ // Rename rather than copy: within one filesystem it is atomic, so a reader
218
+ // watching the directory never sees a half-written file under this name.
219
+ await fs.rename(partialPath, finalPath);
220
+ await fs.chmod(finalPath, ARTIFACT_FILE_MODE);
221
+
222
+ return {
223
+ path: finalPath,
224
+ bytes: written.bytes,
225
+ checksum: written.checksum,
226
+ };
227
+ } catch (error) {
228
+ await fs.rm(partialPath, { force: true });
229
+ throw error;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Remove every partial file left behind in a download directory.
235
+ *
236
+ * @param {string} root - Absolute download directory
237
+ * @returns {Promise<string[]>} Paths that were removed
238
+ */
239
+ export async function cleanPartials(root) {
240
+ let entries;
241
+ try {
242
+ entries = await fs.readdir(root);
243
+ } catch {
244
+ return [];
245
+ }
246
+
247
+ const removed = [];
248
+ for (const entry of entries) {
249
+ if (entry.endsWith('.partial')) {
250
+ const candidate = path.join(root, entry);
251
+ await fs.rm(candidate, { force: true });
252
+ removed.push(candidate);
253
+ }
254
+ }
255
+ return removed;
256
+ }
package/src/exports.js CHANGED
@@ -60,6 +60,7 @@ export {
60
60
  setContent,
61
61
  waitForNavigation,
62
62
  waitForPageReady,
63
+ waitForReady,
63
64
  waitAfterAction,
64
65
  // Navigation verification
65
66
  defaultNavigationVerification,
@@ -115,8 +116,31 @@ export {
115
116
  defaultClickVerification,
116
117
  capturePreClickState,
117
118
  verifyClick,
119
+ // Truthful click result model
120
+ CLICK_STATUS,
121
+ CLICK_EFFECT,
122
+ // Orthogonal activation options
123
+ CLICK_ACTIVATION,
124
+ CLICK_SCROLL,
125
+ CLICK_ACTIONABILITY,
126
+ ScrollConstraintError,
118
127
  } from './interactions/click.js';
119
128
 
129
+ export {
130
+ READINESS_STATUS,
131
+ LONG_LIVED_REQUEST_PATTERNS,
132
+ isLongLivedRequest,
133
+ createDeadline,
134
+ runReadinessChecks,
135
+ // Composable readiness checks
136
+ urlStableFor,
137
+ networkIdleFor,
138
+ domStableFor,
139
+ visibleImages,
140
+ predicate,
141
+ stableCheck,
142
+ } from './core/readiness.js';
143
+
120
144
  export {
121
145
  checkIfElementEmpty,
122
146
  performFill,
@@ -173,3 +197,51 @@ export {
173
197
  findFingerprintLimitation,
174
198
  relevantFingerprintLimitations,
175
199
  } from './fingerprint/limitations.js';
200
+
201
+ // Re-export the managed download subsystem: one lifecycle for automated and
202
+ // manual downloads, with files that outlive the browser (issue #88).
203
+ export {
204
+ attachDownloads,
205
+ normalizeDownloadOptions,
206
+ } from './downloads/attach.js';
207
+ export { DOWNLOAD_EVENT, createDownloadManager } from './downloads/manager.js';
208
+ export {
209
+ ARTIFACT_DIRECTORY_MODE,
210
+ ARTIFACT_FILE_MODE,
211
+ DOWNLOAD_DIRECTORY_PRESETS,
212
+ prepareDownloadDirectory,
213
+ resolveDownloadDirectory,
214
+ } from './downloads/destination.js';
215
+ export {
216
+ DOWNLOAD_CONFLICT,
217
+ cleanPartials,
218
+ saveDownload,
219
+ } from './downloads/store.js';
220
+ export { DOWNLOAD_FAILURE } from './downloads/sources.js';
221
+ export {
222
+ extensionFromContent,
223
+ sanitizeDownloadName,
224
+ withExtension,
225
+ } from './downloads/naming.js';
226
+
227
+ // Re-export the portable trace subsystem: one recorder, one versioned bundle,
228
+ // and one offline viewer for every consumer (issue #87).
229
+ export { startTrace } from './traces/recorder.js';
230
+ export {
231
+ TRACE_EVENT,
232
+ TRACE_EVENT_SOURCES,
233
+ TRACE_FILES,
234
+ TRACE_MODE,
235
+ TRACE_OUTCOME,
236
+ TRACE_SCHEMA_VERSION,
237
+ } from './traces/schema.js';
238
+ export { diffControlState, parseNdjson, readTrace } from './traces/reader.js';
239
+ export { renderTraceViewer, writeTraceViewer } from './traces/viewer.js';
240
+ export {
241
+ DEFAULT_REDACT_ATTRIBUTES,
242
+ DEFAULT_REDACT_SELECTORS,
243
+ REDACTED,
244
+ normalizePrivacyOptions,
245
+ redactUrl,
246
+ redactValue,
247
+ } from './traces/redaction.js';
package/src/factory.js CHANGED
@@ -20,6 +20,8 @@ import {
20
20
  notCondition,
21
21
  } from './core/page-trigger-manager.js';
22
22
  import { createBoundFunctions } from './bindings.js';
23
+ import { attachDownloads } from './downloads/attach.js';
24
+ import { startTrace } from './traces/recorder.js';
23
25
 
24
26
  /**
25
27
  * Create a browser commander instance for a specific page
@@ -28,6 +30,7 @@ import { createBoundFunctions } from './bindings.js';
28
30
  * @param {boolean} options.verbose - Enable verbose logging
29
31
  * @param {boolean} options.enableNetworkTracking - Enable network request tracking (default: true)
30
32
  * @param {boolean} options.enableNavigationManager - Enable navigation manager (default: true)
33
+ * @param {Object} [options.downloads] - A download manager to expose as commander.downloads, such as the one launchBrowser() returned
31
34
  * @returns {Object} - Browser commander API
32
35
  */
33
36
  export function makeBrowserCommander(options = {}) {
@@ -37,6 +40,7 @@ export function makeBrowserCommander(options = {}) {
37
40
  enableNetworkTracking = true,
38
41
  enableNavigationManager = true,
39
42
  enableDialogManager = true,
43
+ downloads = null,
40
44
  } = options;
41
45
 
42
46
  if (!page) {
@@ -108,6 +112,12 @@ export function makeBrowserCommander(options = {}) {
108
112
 
109
113
  // Cleanup function
110
114
  const destroy = async () => {
115
+ // Downloads are disposed first: disposing waits for bytes still being
116
+ // written, and a caller that destroys the commander right after a click
117
+ // should still find the file.
118
+ if (commander.downloads) {
119
+ await commander.downloads.dispose();
120
+ }
111
121
  if (pageTriggerManager) {
112
122
  await pageTriggerManager.destroy();
113
123
  }
@@ -142,6 +152,40 @@ export function makeBrowserCommander(options = {}) {
142
152
  // All bound functions
143
153
  ...boundFunctions,
144
154
 
155
+ // Managed downloads (issue #88). Present once a manager is attached,
156
+ // either by passing the one launchBrowser() returned or by calling
157
+ // configureDownloads() on an existing commander.
158
+ downloads,
159
+
160
+ /**
161
+ * Attach a managed download lifecycle to this commander's browser.
162
+ *
163
+ * @param {boolean|Object} [downloadOptions=true] - true for defaults, or {directory, persist, conflict}
164
+ * @returns {Promise<Object>} The attached download manager
165
+ */
166
+ configureDownloads: async (downloadOptions = true) => {
167
+ // Replacing a manager detaches the old one rather than leaving two
168
+ // sources writing into the same directory.
169
+ await commander.downloads?.dispose();
170
+ commander.downloads = await attachDownloads({
171
+ engine,
172
+ browser: page.context?.() ?? page.browser?.(),
173
+ page,
174
+ downloads: downloadOptions,
175
+ log,
176
+ });
177
+ return commander.downloads;
178
+ },
179
+
180
+ /**
181
+ * Start recording a privacy-aware trace of this session (issue #87).
182
+ *
183
+ * @param {Object} [traceOptions] - {output, mode, screenshots, dom, events, privacy, limits}
184
+ * @returns {Promise<Object>} The running trace: {checkpoint, event, stop}
185
+ */
186
+ startTrace: (traceOptions = {}) =>
187
+ startTrace({ commander, page, log, ...traceOptions }),
188
+
145
189
  // Lifecycle
146
190
  destroy,
147
191
 
@@ -209,6 +253,17 @@ export function makeBrowserCommander(options = {}) {
209
253
  );
210
254
  },
211
255
 
256
+ // Watch dialogs without answering them (issue #87): an observer is told
257
+ // about every dialog and never suppresses the manager's auto-dismissal.
258
+ observeDialogs: dialogManager
259
+ ? (fn) => dialogManager.observeDialogs(fn)
260
+ : () => {
261
+ throw new Error('observeDialogs requires enableDialogManager: true');
262
+ },
263
+ unobserveDialogs: dialogManager
264
+ ? (fn) => dialogManager.unobserveDialogs(fn)
265
+ : () => {},
266
+
212
267
  // URL condition helpers
213
268
  makeUrlCondition,
214
269
  allConditions,