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