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,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,65 @@ 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
+ // A Links Notation view of the same bundle, for consumers that keep semantic,
241
+ // actor-aware histories of a run (issue #94).
242
+ export {
243
+ decodeLinkText,
244
+ encodeLinkText,
245
+ formatTraceLinks,
246
+ traceLinks,
247
+ TRACE_LINK_IDS,
248
+ TRACE_LINK_OUTCOME,
249
+ TRACE_LINKS_FILE,
250
+ TRACE_LINKS_SECTIONS,
251
+ TRACE_LINKS_VERSION,
252
+ writeTraceLinks,
253
+ } from './traces/links.js';
254
+ export {
255
+ DEFAULT_REDACT_ATTRIBUTES,
256
+ DEFAULT_REDACT_SELECTORS,
257
+ REDACTED,
258
+ normalizePrivacyOptions,
259
+ redactUrl,
260
+ redactValue,
261
+ } 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,