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,485 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { setImmediate as yieldToEngine } from 'node:timers/promises';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createDownloadManager,
|
|
9
|
+
DOWNLOAD_EVENT,
|
|
10
|
+
} from '../../../src/downloads/manager.js';
|
|
11
|
+
import { STAGING_DIRECTORY } from '../../../src/downloads/sources.js';
|
|
12
|
+
import {
|
|
13
|
+
createFakeCdpSession,
|
|
14
|
+
createFakeContext,
|
|
15
|
+
makeFakeDownload,
|
|
16
|
+
makeTempDirectory,
|
|
17
|
+
} from '../../helpers/download-fixtures.js';
|
|
18
|
+
|
|
19
|
+
describe('download manager (issue #88)', () => {
|
|
20
|
+
let root;
|
|
21
|
+
let staging;
|
|
22
|
+
let context;
|
|
23
|
+
let manager;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
root = await makeTempDirectory();
|
|
27
|
+
staging = await makeTempDirectory();
|
|
28
|
+
context = createFakeContext();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
await manager?.dispose();
|
|
33
|
+
manager = undefined;
|
|
34
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
35
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Start a manager over the fake Playwright context.
|
|
40
|
+
*
|
|
41
|
+
* @param {Object} [options] - Extra manager options
|
|
42
|
+
* @returns {Promise<Object>} The manager
|
|
43
|
+
*/
|
|
44
|
+
const managerOver = async (options = {}) => {
|
|
45
|
+
manager = await createDownloadManager({
|
|
46
|
+
engine: 'playwright',
|
|
47
|
+
context,
|
|
48
|
+
directory: root,
|
|
49
|
+
...options,
|
|
50
|
+
});
|
|
51
|
+
return manager;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Emit a fake download through the context.
|
|
56
|
+
*
|
|
57
|
+
* @param {Object} [options] - Fake download options
|
|
58
|
+
* @returns {Promise<Object>} The fake download
|
|
59
|
+
*/
|
|
60
|
+
const emitDownload = async (options = {}) => {
|
|
61
|
+
const download = await makeFakeDownload({
|
|
62
|
+
dir: staging,
|
|
63
|
+
suggestedFilename: 'report.pdf',
|
|
64
|
+
...options,
|
|
65
|
+
});
|
|
66
|
+
await context.emitDownload(download);
|
|
67
|
+
// Engine download events are fire-and-forget; the save they start is not.
|
|
68
|
+
await manager.idle();
|
|
69
|
+
return download;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Record every artifact published under one lifecycle event.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} event - Lifecycle event to listen for
|
|
76
|
+
* @returns {Object[]} Artifacts, in the order they were published
|
|
77
|
+
*/
|
|
78
|
+
const collect = (event) => {
|
|
79
|
+
const published = [];
|
|
80
|
+
manager.on(event, (artifact) => published.push(artifact));
|
|
81
|
+
return published;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* List the managed directory, ignoring engine staging.
|
|
86
|
+
*
|
|
87
|
+
* @returns {Promise<string[]>} Sorted entry names
|
|
88
|
+
*/
|
|
89
|
+
const listing = async () =>
|
|
90
|
+
(await fs.readdir(root)).filter((e) => e !== STAGING_DIRECTORY).sort();
|
|
91
|
+
|
|
92
|
+
it('should prepare the download directory before any download arrives', async () => {
|
|
93
|
+
const created = path.join(root, 'nested');
|
|
94
|
+
|
|
95
|
+
await managerOver({ directory: created });
|
|
96
|
+
|
|
97
|
+
assert.strictEqual(manager.directory, created);
|
|
98
|
+
assert.ok((await fs.stat(created)).isDirectory());
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should save a download and report it as completed', async () => {
|
|
102
|
+
await managerOver();
|
|
103
|
+
const completed = collect(DOWNLOAD_EVENT.COMPLETED);
|
|
104
|
+
|
|
105
|
+
await emitDownload({ body: 'report body' });
|
|
106
|
+
|
|
107
|
+
assert.strictEqual(completed.length, 1);
|
|
108
|
+
assert.strictEqual(completed[0].state, DOWNLOAD_EVENT.COMPLETED);
|
|
109
|
+
assert.strictEqual(path.basename(completed[0].path), 'report.pdf');
|
|
110
|
+
assert.strictEqual(completed[0].bytes, 11);
|
|
111
|
+
assert.match(completed[0].checksum, /^[0-9a-f]{64}$/);
|
|
112
|
+
assert.ok(completed[0].id.startsWith('dl-'));
|
|
113
|
+
assert.ok(completed[0].completedAt);
|
|
114
|
+
assert.strictEqual(
|
|
115
|
+
await fs.readFile(completed[0].path, 'utf8'),
|
|
116
|
+
'report body'
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should keep the file after the browser that produced it is gone', async () => {
|
|
121
|
+
await managerOver();
|
|
122
|
+
await emitDownload({ body: 'persisted' });
|
|
123
|
+
const [artifact] = manager.list();
|
|
124
|
+
|
|
125
|
+
await manager.dispose();
|
|
126
|
+
manager = undefined;
|
|
127
|
+
|
|
128
|
+
assert.strictEqual(await fs.readFile(artifact.path, 'utf8'), 'persisted');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should report an engine failure with the engine reason preserved', async () => {
|
|
132
|
+
await managerOver();
|
|
133
|
+
const failed = collect(DOWNLOAD_EVENT.FAILED);
|
|
134
|
+
|
|
135
|
+
await emitDownload({ failure: 'net::ERR_CONNECTION_RESET' });
|
|
136
|
+
|
|
137
|
+
assert.strictEqual(failed.length, 1);
|
|
138
|
+
assert.strictEqual(failed[0].failure, 'net::ERR_CONNECTION_RESET');
|
|
139
|
+
assert.strictEqual(failed[0].path, null);
|
|
140
|
+
assert.deepStrictEqual(await listing(), []);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should report a cancelled download as cancelled, not as a failure', async () => {
|
|
144
|
+
await managerOver();
|
|
145
|
+
const events = [];
|
|
146
|
+
manager.on(DOWNLOAD_EVENT.CANCELLED, () => events.push('cancelled'));
|
|
147
|
+
manager.on(DOWNLOAD_EVENT.FAILED, () => events.push('failed'));
|
|
148
|
+
|
|
149
|
+
await emitDownload({ failure: 'canceled' });
|
|
150
|
+
|
|
151
|
+
assert.deepStrictEqual(events, ['cancelled']);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should report each download once when a listener and a capture both watch', async () => {
|
|
155
|
+
// Issue #88: "a download that a global listener and an awaited capture()
|
|
156
|
+
// both see must be saved once and reported once".
|
|
157
|
+
await managerOver();
|
|
158
|
+
const completed = collect(DOWNLOAD_EVENT.COMPLETED);
|
|
159
|
+
|
|
160
|
+
const captured = await manager.capture({
|
|
161
|
+
action: () => emitDownload({ body: 'once' }),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
assert.strictEqual(completed.length, 1);
|
|
165
|
+
assert.strictEqual(completed[0], captured);
|
|
166
|
+
assert.strictEqual(manager.list().length, 1);
|
|
167
|
+
assert.deepStrictEqual(await listing(), ['report.pdf']);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should catch a download that starts inside the triggering action', async () => {
|
|
171
|
+
// Arming after the action would lose this one: the race the issue calls out.
|
|
172
|
+
await managerOver();
|
|
173
|
+
|
|
174
|
+
const artifact = await manager.capture({
|
|
175
|
+
action: () => emitDownload({ body: 'raced' }),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
assert.strictEqual(artifact.state, DOWNLOAD_EVENT.COMPLETED);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should name a captured download without losing the safety rules', async () => {
|
|
182
|
+
await managerOver();
|
|
183
|
+
|
|
184
|
+
const artifact = await manager.capture({
|
|
185
|
+
action: () =>
|
|
186
|
+
emitDownload({
|
|
187
|
+
suggestedFilename: '8d0f9e2c-4a11-4b22-9f00-1d2e3f405162',
|
|
188
|
+
body: '%PDF-1.7 invoice',
|
|
189
|
+
}),
|
|
190
|
+
filename: () => '../invoice.pdf',
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
assert.strictEqual(artifact.path, path.join(root, 'invoice.pdf'));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should fail a capture whose download does not validate', async () => {
|
|
197
|
+
await managerOver();
|
|
198
|
+
|
|
199
|
+
await assert.rejects(
|
|
200
|
+
manager.capture({
|
|
201
|
+
action: () => emitDownload({ body: '<html>login</html>' }),
|
|
202
|
+
validate: ({ bytes }) => {
|
|
203
|
+
if (bytes < 1000) {
|
|
204
|
+
throw new Error('expected a PDF, got a login page');
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
}),
|
|
208
|
+
/failed: expected a PDF/
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
assert.deepStrictEqual(await listing(), []);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should time out rather than wait forever for a download that never starts', async () => {
|
|
215
|
+
await managerOver();
|
|
216
|
+
|
|
217
|
+
await assert.rejects(
|
|
218
|
+
manager.capture({ action: () => {}, timeout: 50 }),
|
|
219
|
+
/no download completed within 50ms/
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should propagate an error from the triggering action', async () => {
|
|
224
|
+
await managerOver();
|
|
225
|
+
|
|
226
|
+
await assert.rejects(
|
|
227
|
+
manager.capture({
|
|
228
|
+
action: () => {
|
|
229
|
+
throw new Error('the button was not there');
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
/the button was not there/
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should resolve a name collision without replacing the earlier file', async () => {
|
|
237
|
+
await managerOver();
|
|
238
|
+
|
|
239
|
+
await emitDownload({ body: 'first' });
|
|
240
|
+
await emitDownload({ body: 'second' });
|
|
241
|
+
|
|
242
|
+
assert.deepStrictEqual(await listing(), ['report (2).pdf', 'report.pdf']);
|
|
243
|
+
assert.strictEqual(manager.list().length, 2);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should list every download in the order it started', async () => {
|
|
247
|
+
await managerOver();
|
|
248
|
+
|
|
249
|
+
await emitDownload({ suggestedFilename: 'a.txt' });
|
|
250
|
+
await emitDownload({ suggestedFilename: 'b.txt' });
|
|
251
|
+
|
|
252
|
+
assert.deepStrictEqual(
|
|
253
|
+
manager.list().map((artifact) => artifact.suggestedFilename),
|
|
254
|
+
['a.txt', 'b.txt']
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('manual downloads over CDP', () => {
|
|
259
|
+
/**
|
|
260
|
+
* Start a manager whose only source is a fake CDP session.
|
|
261
|
+
*
|
|
262
|
+
* @param {Object} session - Fake CDP session
|
|
263
|
+
* @returns {Promise<Object>} The manager
|
|
264
|
+
*/
|
|
265
|
+
const cdpManagerOver = async (session) =>
|
|
266
|
+
createDownloadManager({
|
|
267
|
+
engine: 'puppeteer',
|
|
268
|
+
directory: root,
|
|
269
|
+
browser: {
|
|
270
|
+
target: () => ({ createCDPSession: async () => session }),
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should ask Chromium to report downloads it did not automate', async () => {
|
|
275
|
+
const session = createFakeCdpSession();
|
|
276
|
+
manager = await cdpManagerOver(session);
|
|
277
|
+
|
|
278
|
+
assert.deepStrictEqual(session.sent, [
|
|
279
|
+
{
|
|
280
|
+
method: 'Browser.setDownloadBehavior',
|
|
281
|
+
params: {
|
|
282
|
+
behavior: 'allowAndName',
|
|
283
|
+
downloadPath: path.join(root, STAGING_DIRECTORY),
|
|
284
|
+
eventsEnabled: true,
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should place a manually started download like an automated one', async () => {
|
|
291
|
+
const session = createFakeCdpSession();
|
|
292
|
+
manager = await cdpManagerOver(session);
|
|
293
|
+
const completed = collect(DOWNLOAD_EVENT.COMPLETED);
|
|
294
|
+
|
|
295
|
+
const guid = 'guid-0001';
|
|
296
|
+
session.emit('Browser.downloadWillBegin', {
|
|
297
|
+
guid,
|
|
298
|
+
url: 'https://example.com/manual.pdf',
|
|
299
|
+
suggestedFilename: 'manual.pdf',
|
|
300
|
+
});
|
|
301
|
+
await fs.writeFile(
|
|
302
|
+
path.join(root, STAGING_DIRECTORY, guid),
|
|
303
|
+
'manual body'
|
|
304
|
+
);
|
|
305
|
+
session.emit('Browser.downloadProgress', { guid, state: 'completed' });
|
|
306
|
+
await manager.idle();
|
|
307
|
+
|
|
308
|
+
assert.strictEqual(completed.length, 1);
|
|
309
|
+
assert.strictEqual(completed[0].path, path.join(root, 'manual.pdf'));
|
|
310
|
+
assert.strictEqual(
|
|
311
|
+
await fs.readFile(completed[0].path, 'utf8'),
|
|
312
|
+
'manual body'
|
|
313
|
+
);
|
|
314
|
+
// The staged copy is the engine's, not the user's: it does not linger.
|
|
315
|
+
assert.deepStrictEqual(
|
|
316
|
+
await fs.readdir(path.join(root, STAGING_DIRECTORY)),
|
|
317
|
+
[]
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should report a download the browser refused', async () => {
|
|
322
|
+
const session = createFakeCdpSession();
|
|
323
|
+
manager = await cdpManagerOver(session);
|
|
324
|
+
const cancelled = collect(DOWNLOAD_EVENT.CANCELLED);
|
|
325
|
+
|
|
326
|
+
session.emit('Browser.downloadWillBegin', {
|
|
327
|
+
guid: 'guid-0002',
|
|
328
|
+
url: 'https://example.com/blocked.exe',
|
|
329
|
+
suggestedFilename: 'blocked.exe',
|
|
330
|
+
});
|
|
331
|
+
session.emit('Browser.downloadProgress', {
|
|
332
|
+
guid: 'guid-0002',
|
|
333
|
+
state: 'canceled',
|
|
334
|
+
});
|
|
335
|
+
await yieldToEngine();
|
|
336
|
+
|
|
337
|
+
assert.strictEqual(cancelled.length, 1);
|
|
338
|
+
assert.match(cancelled[0].failure, /canceled/);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should wait for staged bytes that land after the completion event', async () => {
|
|
342
|
+
// Issue #92: Chromium writes `<guid>.crdownload` and renames it, so the
|
|
343
|
+
// completion event can arrive before `<guid>` is openable. Publishing on
|
|
344
|
+
// the event alone turned a download that succeeded into
|
|
345
|
+
// `ENOENT ... .browser-commander-staging/<guid>`.
|
|
346
|
+
const session = createFakeCdpSession();
|
|
347
|
+
manager = await cdpManagerOver(session);
|
|
348
|
+
const completed = collect(DOWNLOAD_EVENT.COMPLETED);
|
|
349
|
+
const failed = collect(DOWNLOAD_EVENT.FAILED);
|
|
350
|
+
|
|
351
|
+
const guid = 'guid-0010';
|
|
352
|
+
const stagedPath = path.join(root, STAGING_DIRECTORY, guid);
|
|
353
|
+
session.emit('Browser.downloadWillBegin', {
|
|
354
|
+
guid,
|
|
355
|
+
url: 'https://example.com/late.pdf',
|
|
356
|
+
suggestedFilename: 'late.pdf',
|
|
357
|
+
});
|
|
358
|
+
// The event first, the bytes afterwards - the ordering from the report.
|
|
359
|
+
session.emit('Browser.downloadProgress', { guid, state: 'completed' });
|
|
360
|
+
await yieldToEngine();
|
|
361
|
+
assert.deepStrictEqual(completed, [], 'published before the bytes exist');
|
|
362
|
+
await fs.writeFile(stagedPath, 'late body');
|
|
363
|
+
|
|
364
|
+
await manager.idle();
|
|
365
|
+
|
|
366
|
+
assert.deepStrictEqual(failed, []);
|
|
367
|
+
assert.strictEqual(completed.length, 1);
|
|
368
|
+
assert.strictEqual(
|
|
369
|
+
await fs.readFile(completed[0].path, 'utf8'),
|
|
370
|
+
'late body'
|
|
371
|
+
);
|
|
372
|
+
assert.deepStrictEqual(
|
|
373
|
+
await fs.readdir(path.join(root, STAGING_DIRECTORY)),
|
|
374
|
+
[]
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('should not claim bytes while the partial file is still being written', async () => {
|
|
379
|
+
const session = createFakeCdpSession();
|
|
380
|
+
manager = await cdpManagerOver(session);
|
|
381
|
+
const completed = collect(DOWNLOAD_EVENT.COMPLETED);
|
|
382
|
+
|
|
383
|
+
const guid = 'guid-0011';
|
|
384
|
+
const stagedPath = path.join(root, STAGING_DIRECTORY, guid);
|
|
385
|
+
session.emit('Browser.downloadWillBegin', {
|
|
386
|
+
guid,
|
|
387
|
+
url: 'https://example.com/partial.pdf',
|
|
388
|
+
suggestedFilename: 'partial.pdf',
|
|
389
|
+
});
|
|
390
|
+
// A half-written file under the final name, with Chromium's own
|
|
391
|
+
// in-progress marker still beside it.
|
|
392
|
+
await fs.writeFile(stagedPath, 'half');
|
|
393
|
+
await fs.writeFile(`${stagedPath}.crdownload`, 'rest');
|
|
394
|
+
session.emit('Browser.downloadProgress', { guid, state: 'completed' });
|
|
395
|
+
await yieldToEngine();
|
|
396
|
+
await yieldToEngine();
|
|
397
|
+
assert.deepStrictEqual(completed, [], 'claimed a half-written file');
|
|
398
|
+
|
|
399
|
+
await fs.rm(`${stagedPath}.crdownload`);
|
|
400
|
+
await fs.writeFile(stagedPath, 'half and rest');
|
|
401
|
+
await manager.idle();
|
|
402
|
+
|
|
403
|
+
assert.strictEqual(completed.length, 1);
|
|
404
|
+
assert.strictEqual(
|
|
405
|
+
await fs.readFile(completed[0].path, 'utf8'),
|
|
406
|
+
'half and rest'
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('should report a completion whose bytes never arrive as a failure', async () => {
|
|
411
|
+
const session = createFakeCdpSession();
|
|
412
|
+
manager = await createDownloadManager({
|
|
413
|
+
engine: 'puppeteer',
|
|
414
|
+
directory: root,
|
|
415
|
+
browser: {
|
|
416
|
+
target: () => ({ createCDPSession: async () => session }),
|
|
417
|
+
},
|
|
418
|
+
stagingTimeout: 60,
|
|
419
|
+
stagingPollInterval: 5,
|
|
420
|
+
});
|
|
421
|
+
const failed = collect(DOWNLOAD_EVENT.FAILED);
|
|
422
|
+
const completed = collect(DOWNLOAD_EVENT.COMPLETED);
|
|
423
|
+
|
|
424
|
+
session.emit('Browser.downloadWillBegin', {
|
|
425
|
+
guid: 'guid-0012',
|
|
426
|
+
url: 'https://example.com/ghost.pdf',
|
|
427
|
+
suggestedFilename: 'ghost.pdf',
|
|
428
|
+
});
|
|
429
|
+
session.emit('Browser.downloadProgress', {
|
|
430
|
+
guid: 'guid-0012',
|
|
431
|
+
state: 'completed',
|
|
432
|
+
});
|
|
433
|
+
await manager.idle();
|
|
434
|
+
|
|
435
|
+
assert.deepStrictEqual(completed, []);
|
|
436
|
+
assert.strictEqual(failed.length, 1);
|
|
437
|
+
assert.match(failed[0].failure, /was not readable within 60ms/);
|
|
438
|
+
assert.match(failed[0].failure, /guid-0012/);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('should keep a capture waiting until the staged bytes are readable', async () => {
|
|
442
|
+
const session = createFakeCdpSession();
|
|
443
|
+
manager = await cdpManagerOver(session);
|
|
444
|
+
|
|
445
|
+
const guid = 'guid-0013';
|
|
446
|
+
const stagedPath = path.join(root, STAGING_DIRECTORY, guid);
|
|
447
|
+
const artifact = await manager.capture({
|
|
448
|
+
timeout: 5000,
|
|
449
|
+
action: async () => {
|
|
450
|
+
session.emit('Browser.downloadWillBegin', {
|
|
451
|
+
guid,
|
|
452
|
+
url: 'https://example.com/captured.pdf',
|
|
453
|
+
suggestedFilename: 'captured.pdf',
|
|
454
|
+
});
|
|
455
|
+
session.emit('Browser.downloadProgress', {
|
|
456
|
+
guid,
|
|
457
|
+
state: 'completed',
|
|
458
|
+
});
|
|
459
|
+
setTimeout(() => {
|
|
460
|
+
fs.writeFile(stagedPath, 'captured body').catch(() => {});
|
|
461
|
+
}, 40);
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
assert.strictEqual(artifact.state, DOWNLOAD_EVENT.COMPLETED);
|
|
466
|
+
assert.strictEqual(
|
|
467
|
+
await fs.readFile(artifact.path, 'utf8'),
|
|
468
|
+
'captured body'
|
|
469
|
+
);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('should ignore progress for a download it never saw start', async () => {
|
|
473
|
+
const session = createFakeCdpSession();
|
|
474
|
+
manager = await cdpManagerOver(session);
|
|
475
|
+
|
|
476
|
+
session.emit('Browser.downloadProgress', {
|
|
477
|
+
guid: 'unknown',
|
|
478
|
+
state: 'completed',
|
|
479
|
+
});
|
|
480
|
+
await yieldToEngine();
|
|
481
|
+
|
|
482
|
+
assert.deepStrictEqual(manager.list(), []);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
extensionFromContent,
|
|
7
|
+
isInsideRoot,
|
|
8
|
+
renamedCandidate,
|
|
9
|
+
resolveInsideRoot,
|
|
10
|
+
sanitizeDownloadName,
|
|
11
|
+
withExtension,
|
|
12
|
+
} from '../../../src/downloads/naming.js';
|
|
13
|
+
|
|
14
|
+
describe('download naming (issue #88)', () => {
|
|
15
|
+
describe('sanitizeDownloadName', () => {
|
|
16
|
+
it('should keep an ordinary name unchanged', () => {
|
|
17
|
+
assert.strictEqual(
|
|
18
|
+
sanitizeDownloadName('quarterly report.pdf'),
|
|
19
|
+
'quarterly report.pdf'
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should refuse to let a page choose a directory', () => {
|
|
24
|
+
// A suggested filename is page-controlled input. `../../etc/passwd` is a
|
|
25
|
+
// request to write outside the download root, not a name.
|
|
26
|
+
assert.strictEqual(sanitizeDownloadName('../../etc/passwd'), 'passwd');
|
|
27
|
+
assert.strictEqual(
|
|
28
|
+
sanitizeDownloadName('C:\\Windows\\system32\\evil.exe'),
|
|
29
|
+
'evil.exe'
|
|
30
|
+
);
|
|
31
|
+
assert.strictEqual(sanitizeDownloadName('/etc/shadow'), 'shadow');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should strip control characters a name should never contain', () => {
|
|
35
|
+
assert.strictEqual(
|
|
36
|
+
sanitizeDownloadName('re\u0000port\u001f.pdf'),
|
|
37
|
+
'report.pdf'
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should fall back when the page suggests nothing usable', () => {
|
|
42
|
+
assert.strictEqual(sanitizeDownloadName(''), 'download');
|
|
43
|
+
assert.strictEqual(sanitizeDownloadName('..'), 'download');
|
|
44
|
+
assert.strictEqual(sanitizeDownloadName(' '), 'download');
|
|
45
|
+
assert.strictEqual(sanitizeDownloadName(undefined), 'download');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should not slow down on a name padded with whitespace', () => {
|
|
49
|
+
// The padding used to be stripped with `/^\s+|[\s.]+$/`, whose second
|
|
50
|
+
// half retries from every position of a whitespace run that never
|
|
51
|
+
// reaches the end. A page chooses this string, so the cost is an
|
|
52
|
+
// attacker's to set: 40k spaces took 2.8 seconds before the trimming
|
|
53
|
+
// walked in from both ends instead, which takes under a millisecond.
|
|
54
|
+
const hostile = `report${' '.repeat(40_000)}x`;
|
|
55
|
+
|
|
56
|
+
const started = performance.now();
|
|
57
|
+
assert.strictEqual(sanitizeDownloadName(hostile), hostile);
|
|
58
|
+
assert.ok(performance.now() - started < 500);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should defuse reserved device names', () => {
|
|
62
|
+
// `con.txt` is unopenable on Windows, and a library that produces a file
|
|
63
|
+
// nobody can open has not really downloaded anything.
|
|
64
|
+
assert.strictEqual(sanitizeDownloadName('con.txt'), '_con.txt');
|
|
65
|
+
assert.strictEqual(sanitizeDownloadName('LPT1'), '_LPT1');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('withExtension', () => {
|
|
70
|
+
it('should leave a name that already has an extension alone', () => {
|
|
71
|
+
assert.strictEqual(
|
|
72
|
+
withExtension({ name: 'report.pdf', mimeType: 'text/csv' }),
|
|
73
|
+
'report.pdf'
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should name a UUID-like download from its declared type', () => {
|
|
78
|
+
assert.strictEqual(
|
|
79
|
+
withExtension({
|
|
80
|
+
name: '3f2b7c1e-0a41-4f2a-9a55-7b1c2d3e4f50',
|
|
81
|
+
mimeType: 'application/pdf; charset=binary',
|
|
82
|
+
}),
|
|
83
|
+
'3f2b7c1e-0a41-4f2a-9a55-7b1c2d3e4f50.pdf'
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should fall back to the bytes when the server declared nothing', () => {
|
|
88
|
+
assert.strictEqual(
|
|
89
|
+
withExtension({
|
|
90
|
+
name: 'mystery',
|
|
91
|
+
head: Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]),
|
|
92
|
+
}),
|
|
93
|
+
'mystery.pdf'
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should trust the bytes over a generic binary type', () => {
|
|
98
|
+
// `application/octet-stream` is what a server sends when it cannot name
|
|
99
|
+
// the format either, so it must not win over a PDF signature and leave
|
|
100
|
+
// the caller with a `.bin` file it cannot open.
|
|
101
|
+
assert.strictEqual(
|
|
102
|
+
withExtension({
|
|
103
|
+
name: '3f2b7c1e-0a41-4f2a-9a55-7b1c2d3e4f50',
|
|
104
|
+
mimeType: 'application/octet-stream',
|
|
105
|
+
head: Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]),
|
|
106
|
+
}),
|
|
107
|
+
'3f2b7c1e-0a41-4f2a-9a55-7b1c2d3e4f50.pdf'
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should still name a truly unidentifiable binary download', () => {
|
|
112
|
+
assert.strictEqual(
|
|
113
|
+
withExtension({
|
|
114
|
+
name: 'payload',
|
|
115
|
+
mimeType: 'application/octet-stream',
|
|
116
|
+
head: Buffer.from([0x01, 0x02]),
|
|
117
|
+
}),
|
|
118
|
+
'payload.bin'
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should leave the name alone when nothing identifies the format', () => {
|
|
123
|
+
assert.strictEqual(
|
|
124
|
+
withExtension({ name: 'mystery', head: Buffer.from([0x01, 0x02]) }),
|
|
125
|
+
'mystery'
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('extensionFromContent', () => {
|
|
131
|
+
it('should identify formats by their leading bytes', () => {
|
|
132
|
+
assert.strictEqual(
|
|
133
|
+
extensionFromContent(Buffer.from([0x89, 0x50, 0x4e, 0x47])),
|
|
134
|
+
'.png'
|
|
135
|
+
);
|
|
136
|
+
assert.strictEqual(
|
|
137
|
+
extensionFromContent(Buffer.from([0x50, 0x4b, 0x03, 0x04])),
|
|
138
|
+
'.zip'
|
|
139
|
+
);
|
|
140
|
+
assert.strictEqual(extensionFromContent(Buffer.alloc(0)), '');
|
|
141
|
+
assert.strictEqual(extensionFromContent(undefined), '');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('root containment', () => {
|
|
146
|
+
const root = path.resolve('/tmp/downloads');
|
|
147
|
+
|
|
148
|
+
it('should accept a file inside the root', () => {
|
|
149
|
+
assert.strictEqual(isInsideRoot(root, `${root}/report.pdf`), true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should reject the root itself and anything outside it', () => {
|
|
153
|
+
assert.strictEqual(isInsideRoot(root, root), false);
|
|
154
|
+
assert.strictEqual(isInsideRoot(root, '/tmp/report.pdf'), false);
|
|
155
|
+
assert.strictEqual(isInsideRoot(root, `${root}/../report.pdf`), false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should refuse to resolve a path that escapes the root', () => {
|
|
159
|
+
assert.throws(
|
|
160
|
+
() => resolveInsideRoot(root, '../escaped.pdf'),
|
|
161
|
+
/outside the download directory/
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('renamedCandidate', () => {
|
|
167
|
+
it('should number collisions deterministically', () => {
|
|
168
|
+
assert.strictEqual(renamedCandidate('report.pdf', 0), 'report.pdf');
|
|
169
|
+
assert.strictEqual(renamedCandidate('report.pdf', 1), 'report (2).pdf');
|
|
170
|
+
assert.strictEqual(renamedCandidate('report.pdf', 2), 'report (3).pdf');
|
|
171
|
+
assert.strictEqual(renamedCandidate('archive', 1), 'archive (2)');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|