browser-commander 0.17.1 → 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.
- package/.jscpd-baseline.json +7 -18
- package/CHANGELOG.md +48 -0
- package/README.md +84 -0
- package/eslint.config.js +5 -0
- package/package.json +4 -1
- package/scripts/version-and-commit.mjs +13 -2
- 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 +453 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +188 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +72 -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 +323 -0
- package/src/traces/observers.js +246 -0
- package/src/traces/page-capture.js +365 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +437 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +165 -0
- package/src/traces/viewer.js +302 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +188 -0
- package/tests/e2e/traces.e2e.test.js +457 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +109 -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 +198 -0
- package/tests/helpers/trace-server.js +117 -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 +354 -0
- package/tests/unit/downloads/naming.test.js +174 -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/scripts/check-release-format.test.js +4 -1
- package/tests/unit/scripts/push-failure-classifier.test.js +153 -0
- package/tests/unit/scripts/push-with-rebase-retry.test.js +302 -0
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +562 -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 +289 -0
|
@@ -0,0 +1,453 @@
|
|
|
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 {Object} [options.log] - Logger
|
|
89
|
+
* @returns {Promise<Object>} Download manager
|
|
90
|
+
*/
|
|
91
|
+
export async function createDownloadManager(options = {}) {
|
|
92
|
+
const {
|
|
93
|
+
engine = 'playwright',
|
|
94
|
+
browser,
|
|
95
|
+
context: explicitContext,
|
|
96
|
+
page,
|
|
97
|
+
directory,
|
|
98
|
+
persist = true,
|
|
99
|
+
conflict = DOWNLOAD_CONFLICT.RENAME,
|
|
100
|
+
filename,
|
|
101
|
+
validate,
|
|
102
|
+
log,
|
|
103
|
+
} = options;
|
|
104
|
+
|
|
105
|
+
const context =
|
|
106
|
+
explicitContext ??
|
|
107
|
+
(engine === 'playwright'
|
|
108
|
+
? playwrightDownloadContext({ browser, page })
|
|
109
|
+
: browser);
|
|
110
|
+
|
|
111
|
+
// Resolved and probed before a single download can start: a permission
|
|
112
|
+
// problem found afterwards looks like a file that never arrived.
|
|
113
|
+
const root = await prepareDownloadDirectory(
|
|
114
|
+
await resolveDownloadDirectory(directory)
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const emitter = new EventEmitter();
|
|
118
|
+
const artifacts = [];
|
|
119
|
+
const pending = new Map();
|
|
120
|
+
const waiters = new Set();
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
const published = new Set();
|
|
123
|
+
const inFlight = new Set();
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Hand a settled artifact to every waiter armed before it started.
|
|
127
|
+
*
|
|
128
|
+
* @param {Object} artifact - Settled artifact
|
|
129
|
+
* @returns {void}
|
|
130
|
+
*/
|
|
131
|
+
const settleWaiters = (artifact) => {
|
|
132
|
+
for (const waiter of [...waiters]) {
|
|
133
|
+
if (waiter.accepts(artifact)) {
|
|
134
|
+
waiters.delete(waiter);
|
|
135
|
+
waiter.settle(artifact);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Publish an artifact under its lifecycle event exactly once.
|
|
142
|
+
*
|
|
143
|
+
* @param {Object} artifact - Artifact to publish
|
|
144
|
+
* @param {string} event - Lifecycle event name
|
|
145
|
+
* @returns {void}
|
|
146
|
+
*/
|
|
147
|
+
const publish = (artifact, event) => {
|
|
148
|
+
// A download reaches its end once. Both a global listener and an awaited
|
|
149
|
+
// `capture()` read from this same record, so publishing twice would mean
|
|
150
|
+
// one file reported as two.
|
|
151
|
+
if (published.has(artifact)) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
published.add(artifact);
|
|
155
|
+
artifact.state = event;
|
|
156
|
+
emitter.emit(event, artifact);
|
|
157
|
+
settleWaiters(artifact);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const sink = {
|
|
161
|
+
/**
|
|
162
|
+
* Record a download the engine has just announced.
|
|
163
|
+
*
|
|
164
|
+
* @param {Object} start - Engine-reported start details
|
|
165
|
+
* @returns {Object} Artifact record
|
|
166
|
+
*/
|
|
167
|
+
started(start) {
|
|
168
|
+
// One engine download can be announced twice - a context listener and a
|
|
169
|
+
// page listener see the same object - so the engine's own handle is the
|
|
170
|
+
// identity, not the order events arrived in.
|
|
171
|
+
if (seen.has(start.engineHandle)) {
|
|
172
|
+
return pending.get(start.engineHandle);
|
|
173
|
+
}
|
|
174
|
+
seen.add(start.engineHandle);
|
|
175
|
+
|
|
176
|
+
const artifact = {
|
|
177
|
+
id: nextDownloadId(),
|
|
178
|
+
url: start.url,
|
|
179
|
+
suggestedFilename: start.suggestedFilename,
|
|
180
|
+
state: DOWNLOAD_EVENT.STARTED,
|
|
181
|
+
startedAt: new Date().toISOString(),
|
|
182
|
+
completedAt: null,
|
|
183
|
+
path: null,
|
|
184
|
+
mimeType: start.mimeType ?? null,
|
|
185
|
+
bytes: null,
|
|
186
|
+
checksum: null,
|
|
187
|
+
failure: null,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
pending.set(start.engineHandle, artifact);
|
|
191
|
+
artifacts.push(artifact);
|
|
192
|
+
emitter.emit(DOWNLOAD_EVENT.STARTED, artifact);
|
|
193
|
+
return artifact;
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Save a download whose bytes the engine has finished writing.
|
|
198
|
+
*
|
|
199
|
+
* Engine events are fire-and-forget, so the save is registered as in-flight
|
|
200
|
+
* work: otherwise `dispose()` could return while bytes are still being
|
|
201
|
+
* written and leave a `.partial` file as the only trace of the download.
|
|
202
|
+
*
|
|
203
|
+
* @param {Object} artifact - Artifact record
|
|
204
|
+
* @param {Object} source - `{path}` or `{stream}` to read the bytes from
|
|
205
|
+
* @returns {Promise<void>} Resolves once the artifact has been published
|
|
206
|
+
*/
|
|
207
|
+
finished(artifact, source) {
|
|
208
|
+
const work = save(artifact, source);
|
|
209
|
+
inFlight.add(work);
|
|
210
|
+
return work.finally(() => inFlight.delete(work));
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Record a download that ended without a file.
|
|
215
|
+
*
|
|
216
|
+
* @param {Object} artifact - Artifact record
|
|
217
|
+
* @param {string} kind - {@link DOWNLOAD_FAILURE} classification
|
|
218
|
+
* @param {string} reason - Engine-reported reason, kept verbatim
|
|
219
|
+
* @returns {void}
|
|
220
|
+
*/
|
|
221
|
+
failed(artifact, kind, reason) {
|
|
222
|
+
if (!artifact) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
artifact.failure = reason;
|
|
226
|
+
publish(
|
|
227
|
+
artifact,
|
|
228
|
+
kind === DOWNLOAD_FAILURE.CANCELLED
|
|
229
|
+
? DOWNLOAD_EVENT.CANCELLED
|
|
230
|
+
: DOWNLOAD_EVENT.FAILED
|
|
231
|
+
);
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Place a finished download's bytes under their final name.
|
|
237
|
+
*
|
|
238
|
+
* @param {Object} artifact - Artifact record
|
|
239
|
+
* @param {Object} source - `{path}` or `{stream}` to read the bytes from
|
|
240
|
+
* @returns {Promise<void>}
|
|
241
|
+
*/
|
|
242
|
+
async function save(artifact, source) {
|
|
243
|
+
if (!artifact || artifact.state !== DOWNLOAD_EVENT.STARTED) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const saved = await saveDownload({
|
|
249
|
+
root,
|
|
250
|
+
source,
|
|
251
|
+
suggestedFilename: artifact.suggestedFilename,
|
|
252
|
+
mimeType: artifact.mimeType,
|
|
253
|
+
conflict: artifact.conflict ?? conflict,
|
|
254
|
+
filename: artifact.filename ?? filename,
|
|
255
|
+
validate: artifact.validate ?? validate,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
Object.assign(artifact, saved, {
|
|
259
|
+
completedAt: new Date().toISOString(),
|
|
260
|
+
});
|
|
261
|
+
publish(artifact, DOWNLOAD_EVENT.COMPLETED);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
artifact.failure = error.message;
|
|
264
|
+
publish(artifact, DOWNLOAD_EVENT.FAILED);
|
|
265
|
+
log?.warn?.(`download ${artifact.id} could not be saved`);
|
|
266
|
+
} finally {
|
|
267
|
+
if (source.removeSource && source.path) {
|
|
268
|
+
await fs.rm(source.path, { force: true });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Wait until every download the manager has seen has been placed.
|
|
275
|
+
*
|
|
276
|
+
* @returns {Promise<void>}
|
|
277
|
+
*/
|
|
278
|
+
const idle = async () => {
|
|
279
|
+
while (inFlight.size > 0) {
|
|
280
|
+
await Promise.all([...inFlight]);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const attached = [];
|
|
285
|
+
|
|
286
|
+
// CDP is what makes a download a *person* started observable, and it is the
|
|
287
|
+
// only source Puppeteer has, so it is tried first. It is also exclusive:
|
|
288
|
+
// `Browser.setDownloadBehavior` takes the bytes away from Playwright's own
|
|
289
|
+
// download handling, so attaching both sources would mean one download
|
|
290
|
+
// reported twice and a Playwright `path()` pointing at a file that was
|
|
291
|
+
// never written.
|
|
292
|
+
let cdpSession;
|
|
293
|
+
try {
|
|
294
|
+
cdpSession = await openBrowserCdpSession({ engine, browser, page });
|
|
295
|
+
if (cdpSession) {
|
|
296
|
+
attached.push(await attachCdpSource({ session: cdpSession, root, sink }));
|
|
297
|
+
}
|
|
298
|
+
} catch (error) {
|
|
299
|
+
cdpSession = undefined;
|
|
300
|
+
log?.warn?.(`manual downloads are not observable: ${error.message}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Without Browser-domain access - an attached browser that refuses it, or a
|
|
304
|
+
// context with no page to borrow a session from - Playwright's own event
|
|
305
|
+
// still covers every automated download.
|
|
306
|
+
if (attached.length === 0 && engine === 'playwright' && context?.on) {
|
|
307
|
+
attached.push(attachPlaywrightSource({ context, sink }));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (attached.length === 0) {
|
|
311
|
+
log?.warn?.(
|
|
312
|
+
'no download source could be attached: downloads will not be managed'
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Wait for a download, optionally triggering it first.
|
|
318
|
+
*
|
|
319
|
+
* @param {Object} [captureOptions] - Capture options
|
|
320
|
+
* @param {Function} [captureOptions.action] - Action that triggers the download
|
|
321
|
+
* @param {Function|string} [captureOptions.filename] - Name for this download
|
|
322
|
+
* @param {number} [captureOptions.timeout] - Budget for the whole capture
|
|
323
|
+
* @param {Function} [captureOptions.validate] - Validation for this download
|
|
324
|
+
* @returns {Promise<Object>} The settled artifact
|
|
325
|
+
*/
|
|
326
|
+
const capture = async (captureOptions = {}) => {
|
|
327
|
+
const {
|
|
328
|
+
action,
|
|
329
|
+
filename: captureFilename,
|
|
330
|
+
timeout = DEFAULT_CAPTURE_TIMEOUT,
|
|
331
|
+
validate: captureValidate,
|
|
332
|
+
} = captureOptions;
|
|
333
|
+
|
|
334
|
+
const deadline = createDeadline({ timeout });
|
|
335
|
+
const armedAt = artifacts.length;
|
|
336
|
+
|
|
337
|
+
let settle;
|
|
338
|
+
const settled = new Promise((resolve) => {
|
|
339
|
+
settle = resolve;
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const waiter = {
|
|
343
|
+
/**
|
|
344
|
+
* Accept the first download that started after this waiter was armed.
|
|
345
|
+
*
|
|
346
|
+
* @param {Object} artifact - Settled artifact
|
|
347
|
+
* @returns {boolean} Whether this waiter claims it
|
|
348
|
+
*/
|
|
349
|
+
accepts: (artifact) => artifacts.indexOf(artifact) >= armedAt,
|
|
350
|
+
settle,
|
|
351
|
+
};
|
|
352
|
+
waiters.add(waiter);
|
|
353
|
+
|
|
354
|
+
// Per-download naming and validation are attached to whichever download
|
|
355
|
+
// this waiter claims, so they cannot leak onto an unrelated one.
|
|
356
|
+
const claim = (artifact) => {
|
|
357
|
+
if (captureFilename !== undefined) {
|
|
358
|
+
artifact.filename =
|
|
359
|
+
typeof captureFilename === 'function'
|
|
360
|
+
? captureFilename
|
|
361
|
+
: () => captureFilename;
|
|
362
|
+
}
|
|
363
|
+
if (captureValidate) {
|
|
364
|
+
artifact.validate = captureValidate;
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const onStarted = (artifact) => {
|
|
369
|
+
if (waiter.accepts(artifact)) {
|
|
370
|
+
claim(artifact);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
emitter.on(DOWNLOAD_EVENT.STARTED, onStarted);
|
|
374
|
+
|
|
375
|
+
// Both the waiter and the naming rules are armed before the action runs.
|
|
376
|
+
// A download can start, finish and be saved inside `action()` - attaching
|
|
377
|
+
// them afterwards would save the file under the wrong name and skip the
|
|
378
|
+
// caller's validation entirely.
|
|
379
|
+
try {
|
|
380
|
+
if (action) {
|
|
381
|
+
await action();
|
|
382
|
+
}
|
|
383
|
+
} catch (error) {
|
|
384
|
+
waiters.delete(waiter);
|
|
385
|
+
emitter.off(DOWNLOAD_EVENT.STARTED, onStarted);
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let timer;
|
|
390
|
+
const expiry = new Promise((_resolve, reject) => {
|
|
391
|
+
timer = setTimeout(
|
|
392
|
+
() =>
|
|
393
|
+
reject(
|
|
394
|
+
new Error(
|
|
395
|
+
`no download completed within ${timeout}ms of the triggering action`
|
|
396
|
+
)
|
|
397
|
+
),
|
|
398
|
+
Math.max(1, deadline.remainingMs())
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
const artifact = await Promise.race([settled, expiry]);
|
|
404
|
+
if (artifact.state !== DOWNLOAD_EVENT.COMPLETED) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`download ${artifact.id} ${artifact.state}: ${artifact.failure}`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return artifact;
|
|
410
|
+
} finally {
|
|
411
|
+
clearTimeout(timer);
|
|
412
|
+
waiters.delete(waiter);
|
|
413
|
+
emitter.off(DOWNLOAD_EVENT.STARTED, onStarted);
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
directory: root,
|
|
419
|
+
persist,
|
|
420
|
+
conflict,
|
|
421
|
+
/**
|
|
422
|
+
* List every download seen in this session.
|
|
423
|
+
*
|
|
424
|
+
* @returns {Object[]} Artifacts in the order they started
|
|
425
|
+
*/
|
|
426
|
+
list: () => [...artifacts],
|
|
427
|
+
capture,
|
|
428
|
+
idle,
|
|
429
|
+
on: (event, listener) => emitter.on(event, listener),
|
|
430
|
+
once: (event, listener) => emitter.once(event, listener),
|
|
431
|
+
off: (event, listener) => emitter.off(event, listener),
|
|
432
|
+
/**
|
|
433
|
+
* Stop observing downloads.
|
|
434
|
+
*
|
|
435
|
+
* Saved files are untouched: persistence is the point of the manager, so
|
|
436
|
+
* detaching must never be the thing that removes a user's file.
|
|
437
|
+
*
|
|
438
|
+
* @returns {Promise<void>}
|
|
439
|
+
*/
|
|
440
|
+
dispose: async () => {
|
|
441
|
+
// A download still being written is finished first: detaching must not
|
|
442
|
+
// be the reason a file only ever exists under its `.partial` name.
|
|
443
|
+
await idle();
|
|
444
|
+
for (const handle of attached) {
|
|
445
|
+
await handle.detach();
|
|
446
|
+
}
|
|
447
|
+
await cdpSession?.detach?.().catch?.(() => {});
|
|
448
|
+
emitter.removeAllListeners();
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export { classifyFailure, DOWNLOAD_CONFLICT, DOWNLOAD_FAILURE };
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Naming and placement rules for managed downloads (issue #88).
|
|
3
|
+
*
|
|
4
|
+
* Everything here answers one question: given a name a *page* chose, where is
|
|
5
|
+
* it safe to write the bytes? A suggested filename is attacker-controlled
|
|
6
|
+
* input, so it is treated as a hint and never as a path.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/** The type a server sends when it cannot name the format either. */
|
|
12
|
+
const GENERIC_BINARY_TYPE = 'application/octet-stream';
|
|
13
|
+
|
|
14
|
+
/** Extensions derived from a declared or detected MIME type. */
|
|
15
|
+
const MIME_EXTENSIONS = new Map([
|
|
16
|
+
['application/pdf', '.pdf'],
|
|
17
|
+
['application/json', '.json'],
|
|
18
|
+
['application/zip', '.zip'],
|
|
19
|
+
['application/gzip', '.gz'],
|
|
20
|
+
['application/x-tar', '.tar'],
|
|
21
|
+
[GENERIC_BINARY_TYPE, '.bin'],
|
|
22
|
+
['application/vnd.ms-excel', '.xls'],
|
|
23
|
+
[
|
|
24
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
25
|
+
'.xlsx',
|
|
26
|
+
],
|
|
27
|
+
['application/msword', '.doc'],
|
|
28
|
+
[
|
|
29
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
30
|
+
'.docx',
|
|
31
|
+
],
|
|
32
|
+
['text/csv', '.csv'],
|
|
33
|
+
['text/html', '.html'],
|
|
34
|
+
['text/plain', '.txt'],
|
|
35
|
+
['image/png', '.png'],
|
|
36
|
+
['image/jpeg', '.jpg'],
|
|
37
|
+
['image/gif', '.gif'],
|
|
38
|
+
['image/svg+xml', '.svg'],
|
|
39
|
+
['image/webp', '.webp'],
|
|
40
|
+
['video/mp4', '.mp4'],
|
|
41
|
+
['audio/mpeg', '.mp3'],
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** Leading bytes that identify a format regardless of what the page claimed. */
|
|
45
|
+
const MAGIC_NUMBERS = [
|
|
46
|
+
{ extension: '.pdf', bytes: [0x25, 0x50, 0x44, 0x46] },
|
|
47
|
+
{ extension: '.png', bytes: [0x89, 0x50, 0x4e, 0x47] },
|
|
48
|
+
{ extension: '.gif', bytes: [0x47, 0x49, 0x46, 0x38] },
|
|
49
|
+
{ extension: '.jpg', bytes: [0xff, 0xd8, 0xff] },
|
|
50
|
+
{ extension: '.zip', bytes: [0x50, 0x4b, 0x03, 0x04] },
|
|
51
|
+
{ extension: '.gz', bytes: [0x1f, 0x8b] },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** Name used when a page suggests nothing usable at all. */
|
|
55
|
+
const FALLBACK_NAME = 'download';
|
|
56
|
+
|
|
57
|
+
/** Windows device names that cannot be used as files even on other systems. */
|
|
58
|
+
const RESERVED_NAMES = new Set([
|
|
59
|
+
'con',
|
|
60
|
+
'prn',
|
|
61
|
+
'aux',
|
|
62
|
+
'nul',
|
|
63
|
+
...Array.from({ length: 9 }, (_, index) => `com${index + 1}`),
|
|
64
|
+
...Array.from({ length: 9 }, (_, index) => `lpt${index + 1}`),
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/** A name that is only dots carries no information and is not a name. */
|
|
68
|
+
const ONLY_DOTS = /^\.+$/;
|
|
69
|
+
|
|
70
|
+
/** One whitespace character, tested one position at a time. */
|
|
71
|
+
const WHITESPACE = /\s/;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Trim the padding off a suggested name: leading whitespace, and trailing
|
|
75
|
+
* whitespace or dots.
|
|
76
|
+
*
|
|
77
|
+
* `/^\s+|[\s.]+$/` says the same thing in one line, but it backtracks
|
|
78
|
+
* quadratically over a long run of whitespace, and this string was chosen by
|
|
79
|
+
* the page. Walking in from both ends is one pass and cannot be made to cost
|
|
80
|
+
* more. Leading dots survive on purpose: `.bashrc` is a name, not padding.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} value - Name to trim
|
|
83
|
+
* @returns {string} The name without its padding
|
|
84
|
+
*/
|
|
85
|
+
function trimNameEdges(value) {
|
|
86
|
+
let start = 0;
|
|
87
|
+
let end = value.length;
|
|
88
|
+
|
|
89
|
+
while (start < end && WHITESPACE.test(value[start])) {
|
|
90
|
+
start += 1;
|
|
91
|
+
}
|
|
92
|
+
while (
|
|
93
|
+
end > start &&
|
|
94
|
+
(value[end - 1] === '.' || WHITESPACE.test(value[end - 1]))
|
|
95
|
+
) {
|
|
96
|
+
end -= 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return value.slice(start, end);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Strip a page-supplied name down to something that can only ever be a file
|
|
104
|
+
* inside the download root.
|
|
105
|
+
*
|
|
106
|
+
* Path separators, drive letters, control characters and `..` segments are
|
|
107
|
+
* removed rather than rejected, because a rejected download is a lost download
|
|
108
|
+
* and the caller asked for the file, not for the page's spelling of it.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} suggested - Name suggested by the page or engine
|
|
111
|
+
* @returns {string} A single safe path segment
|
|
112
|
+
*/
|
|
113
|
+
export function sanitizeDownloadName(suggested) {
|
|
114
|
+
const raw = typeof suggested === 'string' ? suggested : '';
|
|
115
|
+
|
|
116
|
+
// Take the last segment under both separators: a page may suggest
|
|
117
|
+
// `../../etc/passwd` or `C:\Windows\system32\x`, and neither is a location
|
|
118
|
+
// we are willing to honor.
|
|
119
|
+
const lastSegment = raw.split(/[/\\]/).pop() ?? '';
|
|
120
|
+
|
|
121
|
+
const cleaned = trimNameEdges(
|
|
122
|
+
lastSegment
|
|
123
|
+
// eslint-disable-next-line no-control-regex -- control characters in a filename are exactly what we are removing
|
|
124
|
+
.replace(/[\u0000-\u001f\u007f]/g, '')
|
|
125
|
+
.replace(/[:*?"<>|]/g, '_')
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (!cleaned || ONLY_DOTS.test(cleaned)) {
|
|
129
|
+
return FALLBACK_NAME;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const stem = cleaned.slice(0, cleaned.lastIndexOf('.') + 1 || undefined);
|
|
133
|
+
if (RESERVED_NAMES.has(stem.replace(/\.$/, '').toLowerCase())) {
|
|
134
|
+
return `_${cleaned}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return cleaned;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Guess an extension from the first bytes of the file.
|
|
142
|
+
*
|
|
143
|
+
* @param {Buffer|Uint8Array|undefined} head - Leading bytes of the download
|
|
144
|
+
* @returns {string} Extension including the dot, or an empty string
|
|
145
|
+
*/
|
|
146
|
+
export function extensionFromContent(head) {
|
|
147
|
+
if (!head || head.length === 0) {
|
|
148
|
+
return '';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const found = MAGIC_NUMBERS.find(({ bytes }) =>
|
|
152
|
+
bytes.every((byte, index) => head[index] === byte)
|
|
153
|
+
);
|
|
154
|
+
return found ? found.extension : '';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Give a name an extension when the page did not supply one.
|
|
159
|
+
*
|
|
160
|
+
* Pages hand out UUID-like names constantly; a file called
|
|
161
|
+
* `7f1c...-9ab2` is unusable to a human and unopenable by the OS, so the
|
|
162
|
+
* declared MIME type (or the bytes themselves) supplies the missing suffix.
|
|
163
|
+
*
|
|
164
|
+
* @param {Object} options - Naming options
|
|
165
|
+
* @param {string} options.name - Sanitized name
|
|
166
|
+
* @param {string} [options.mimeType] - MIME type declared by the server
|
|
167
|
+
* @param {Buffer|Uint8Array} [options.head] - Leading bytes of the file
|
|
168
|
+
* @returns {string} Name with an extension when one could be determined
|
|
169
|
+
*/
|
|
170
|
+
export function withExtension({ name, mimeType, head }) {
|
|
171
|
+
if (path.extname(name)) {
|
|
172
|
+
return name;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const declaredType = String(mimeType ?? '')
|
|
176
|
+
.split(';')[0]
|
|
177
|
+
.trim()
|
|
178
|
+
.toLowerCase();
|
|
179
|
+
const declared = MIME_EXTENSIONS.get(declaredType);
|
|
180
|
+
const sniffed = extensionFromContent(head);
|
|
181
|
+
// `application/octet-stream` is what a server sends when it does not know
|
|
182
|
+
// either, so the bytes outrank it; any other declared type is a real claim.
|
|
183
|
+
const extension =
|
|
184
|
+
declaredType === GENERIC_BINARY_TYPE
|
|
185
|
+
? sniffed || declared
|
|
186
|
+
: declared || sniffed;
|
|
187
|
+
return extension ? `${name}${extension}` : name;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Report whether a resolved path stays inside the download root.
|
|
192
|
+
*
|
|
193
|
+
* The check is done on resolved paths rather than on the name, so a symlinked
|
|
194
|
+
* or relative root cannot be used to step outside it.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} root - Absolute download directory
|
|
197
|
+
* @param {string} candidate - Absolute candidate path
|
|
198
|
+
* @returns {boolean} Whether the candidate is inside the root
|
|
199
|
+
*/
|
|
200
|
+
export function isInsideRoot(root, candidate) {
|
|
201
|
+
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
202
|
+
return (
|
|
203
|
+
relative !== '' &&
|
|
204
|
+
!relative.startsWith('..') &&
|
|
205
|
+
!path.isAbsolute(relative) &&
|
|
206
|
+
!relative.split(path.sep).includes('..')
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Resolve a safe absolute path for a download inside the root.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} root - Absolute download directory
|
|
214
|
+
* @param {string} name - Sanitized file name
|
|
215
|
+
* @returns {string} Absolute path inside the root
|
|
216
|
+
* @throws {Error} When the name would escape the root
|
|
217
|
+
*/
|
|
218
|
+
export function resolveInsideRoot(root, name) {
|
|
219
|
+
const candidate = path.resolve(root, name);
|
|
220
|
+
if (!isInsideRoot(root, candidate)) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`refusing to write "${name}" outside the download directory ${root}`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return candidate;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Produce the candidate names a rename-on-conflict policy tries, in order.
|
|
230
|
+
*
|
|
231
|
+
* `report.pdf`, `report (2).pdf`, `report (3).pdf`, … — the order is fixed, so
|
|
232
|
+
* two runs of the same scenario produce the same names.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} name - Sanitized file name
|
|
235
|
+
* @param {number} attempt - Zero-based attempt number
|
|
236
|
+
* @returns {string} Candidate name for that attempt
|
|
237
|
+
*/
|
|
238
|
+
export function renamedCandidate(name, attempt) {
|
|
239
|
+
if (attempt === 0) {
|
|
240
|
+
return name;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const extension = path.extname(name);
|
|
244
|
+
const stem = extension ? name.slice(0, -extension.length) : name;
|
|
245
|
+
return `${stem} (${attempt + 1})${extension}`;
|
|
246
|
+
}
|