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.
- package/.jscpd-baseline.json +12 -17
- package/CHANGELOG.md +68 -0
- package/README.md +132 -0
- package/eslint.config.js +5 -0
- package/package.json +5 -1
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +476 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +238 -0
- package/src/downloads/staging.js +146 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +86 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +338 -0
- package/src/traces/deadline.js +33 -0
- package/src/traces/identity.js +127 -0
- package/src/traces/init-script.js +95 -0
- package/src/traces/links.js +623 -0
- package/src/traces/mutation-stream.js +232 -0
- package/src/traces/observers.js +272 -0
- package/src/traces/page-capture.js +601 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +454 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +229 -0
- package/src/traces/viewer.js +448 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +235 -0
- package/tests/e2e/traces.e2e.test.js +742 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +129 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +274 -0
- package/tests/helpers/trace-server.js +173 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +485 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/staging.test.js +117 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/links.test.js +557 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +730 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- 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,
|