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.
Files changed (70) hide show
  1. package/.jscpd-baseline.json +7 -18
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +84 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +4 -1
  6. package/scripts/version-and-commit.mjs +13 -2
  7. package/src/bindings.js +13 -1
  8. package/src/browser/connector.js +15 -3
  9. package/src/browser/launcher.js +14 -2
  10. package/src/browser/navigation.js +63 -0
  11. package/src/core/dialog-manager.js +48 -0
  12. package/src/core/navigation-manager.js +83 -87
  13. package/src/core/navigation-readiness.js +133 -0
  14. package/src/core/readiness.js +478 -0
  15. package/src/downloads/attach.js +58 -0
  16. package/src/downloads/destination.js +125 -0
  17. package/src/downloads/manager.js +453 -0
  18. package/src/downloads/naming.js +246 -0
  19. package/src/downloads/sources.js +188 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +72 -0
  22. package/src/factory.js +55 -0
  23. package/src/interactions/click-activation.js +317 -0
  24. package/src/interactions/click-result.js +108 -0
  25. package/src/interactions/click.js +766 -352
  26. package/src/tests/index.js +75 -11
  27. package/src/tests/tracing.js +236 -0
  28. package/src/traces/bundle.js +323 -0
  29. package/src/traces/observers.js +246 -0
  30. package/src/traces/page-capture.js +365 -0
  31. package/src/traces/reader.js +226 -0
  32. package/src/traces/recorder.js +437 -0
  33. package/src/traces/redaction.js +231 -0
  34. package/src/traces/schema.js +165 -0
  35. package/src/traces/viewer.js +302 -0
  36. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  37. package/tests/e2e/downloads.e2e.test.js +188 -0
  38. package/tests/e2e/traces.e2e.test.js +457 -0
  39. package/tests/helpers/click-fixtures.js +62 -0
  40. package/tests/helpers/download-fixtures.js +125 -0
  41. package/tests/helpers/download-server.js +109 -0
  42. package/tests/helpers/e2e-browser.js +67 -0
  43. package/tests/helpers/file-modes.js +31 -0
  44. package/tests/helpers/fixture-server.js +56 -0
  45. package/tests/helpers/mocks.js +63 -66
  46. package/tests/helpers/readiness-server.js +96 -0
  47. package/tests/helpers/temp-directory.js +44 -0
  48. package/tests/helpers/trace-fixtures.js +198 -0
  49. package/tests/helpers/trace-server.js +117 -0
  50. package/tests/unit/core/dialog-manager.test.js +118 -10
  51. package/tests/unit/core/navigation-manager.test.js +162 -0
  52. package/tests/unit/core/readiness.test.js +517 -0
  53. package/tests/unit/downloads/attach.test.js +220 -0
  54. package/tests/unit/downloads/destination.test.js +114 -0
  55. package/tests/unit/downloads/manager.test.js +354 -0
  56. package/tests/unit/downloads/naming.test.js +174 -0
  57. package/tests/unit/downloads/store.test.js +215 -0
  58. package/tests/unit/interactions/click-activation.test.js +283 -0
  59. package/tests/unit/interactions/click-result.test.js +90 -0
  60. package/tests/unit/interactions/click.test.js +409 -200
  61. package/tests/unit/scripts/check-release-format.test.js +4 -1
  62. package/tests/unit/scripts/push-failure-classifier.test.js +153 -0
  63. package/tests/unit/scripts/push-with-rebase-retry.test.js +302 -0
  64. package/tests/unit/tests/tracing.test.js +299 -0
  65. package/tests/unit/traces/bundle.test.js +260 -0
  66. package/tests/unit/traces/reader.test.js +208 -0
  67. package/tests/unit/traces/recorder.test.js +562 -0
  68. package/tests/unit/traces/redaction.test.js +170 -0
  69. package/tests/unit/traces/schema.test.js +92 -0
  70. package/tests/unit/traces/viewer.test.js +289 -0
@@ -0,0 +1,220 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ import {
7
+ attachDownloads,
8
+ normalizeDownloadOptions,
9
+ } from '../../../src/downloads/attach.js';
10
+ import { connectBrowserWithDependencies } from '../../../src/browser/connector.js';
11
+ import { makeBrowserCommander } from '../../../src/factory.js';
12
+ import {
13
+ createFakeContext,
14
+ makeFakeDownload,
15
+ useTempDownloadDirectory,
16
+ } from '../../helpers/download-fixtures.js';
17
+
18
+ /**
19
+ * A page that belongs to the given context, as Playwright's page does.
20
+ *
21
+ * @param {Object} context - Fake browser context
22
+ * @returns {Object} Minimal page object
23
+ */
24
+ function pageOf(context) {
25
+ return {
26
+ context: () => context,
27
+ // detectEngine() recognizes Playwright by locator() plus context().
28
+ locator: () => ({}),
29
+ on: () => {},
30
+ off: () => {},
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Build a commander over a fake context, with only the download wiring on.
36
+ *
37
+ * @param {Object} context - Fake browser context
38
+ * @param {Object} [downloads] - A manager to expose as commander.downloads
39
+ * @returns {Object} Browser commander
40
+ */
41
+ function commanderOver(context, downloads) {
42
+ return makeBrowserCommander({
43
+ page: pageOf(context),
44
+ enableNetworkTracking: false,
45
+ enableNavigationManager: false,
46
+ enableDialogManager: false,
47
+ downloads,
48
+ });
49
+ }
50
+
51
+ describe('download attachment (issue #88)', () => {
52
+ const directory = useTempDownloadDirectory();
53
+
54
+ describe('normalizeDownloadOptions', () => {
55
+ it('should treat an absent or disabled option as no manager', () => {
56
+ assert.strictEqual(normalizeDownloadOptions(undefined), null);
57
+ assert.strictEqual(normalizeDownloadOptions(false), null);
58
+ assert.strictEqual(normalizeDownloadOptions(null), null);
59
+ });
60
+
61
+ it('should read true as "manage downloads with the defaults"', () => {
62
+ assert.deepStrictEqual(normalizeDownloadOptions(true), {});
63
+ });
64
+
65
+ it('should pass an options object through untouched', () => {
66
+ const options = { directory: '/tmp/x', conflict: 'overwrite' };
67
+ assert.strictEqual(normalizeDownloadOptions(options), options);
68
+ });
69
+
70
+ it('should refuse a value that cannot describe a directory', () => {
71
+ assert.throws(
72
+ () => normalizeDownloadOptions('/tmp/downloads'),
73
+ /downloads must be true, false or an options object/
74
+ );
75
+ });
76
+ });
77
+
78
+ it('should attach nothing when the caller did not ask for downloads', () => {
79
+ assert.strictEqual(
80
+ attachDownloads({ engine: 'playwright', browser: createFakeContext() }),
81
+ null
82
+ );
83
+ });
84
+
85
+ it('should manage downloads from the context it was attached to', async () => {
86
+ const context = createFakeContext();
87
+ const manager = await attachDownloads({
88
+ engine: 'playwright',
89
+ browser: context,
90
+ downloads: { directory: directory.path },
91
+ });
92
+
93
+ await context.emitDownload(
94
+ await makeFakeDownload({
95
+ dir: directory.path,
96
+ suggestedFilename: 'a.txt',
97
+ })
98
+ );
99
+ await manager.idle();
100
+
101
+ assert.deepStrictEqual(
102
+ manager.list().map((artifact) => path.basename(artifact.path)),
103
+ ['a.txt']
104
+ );
105
+ await manager.dispose();
106
+ });
107
+
108
+ it('should give a connected browser the same lifecycle as a launched one', async () => {
109
+ // Issue #88 asks for one manager "identical across launchBrowser(),
110
+ // connectBrowser() and launchRealBrowser()", so the connector must return
111
+ // the same handle rather than leaving attached browsers unmanaged.
112
+ const context = createFakeContext();
113
+ const browser = { contexts: () => [context] };
114
+ context.pages = () => [pageOf(context)];
115
+
116
+ const connection = await connectBrowserWithDependencies(
117
+ {
118
+ engine: 'playwright',
119
+ cdpEndpoint: 'http://127.0.0.1:9222',
120
+ downloads: { directory: directory.path },
121
+ },
122
+ {
123
+ loadPlaywright: async () => ({
124
+ chromium: { connectOverCDP: async () => browser },
125
+ }),
126
+ }
127
+ );
128
+
129
+ assert.ok(connection.downloads, 'connectBrowser should return a manager');
130
+ assert.strictEqual(connection.downloads.directory, directory.path);
131
+
132
+ await context.emitDownload(
133
+ await makeFakeDownload({
134
+ dir: directory.path,
135
+ suggestedFilename: 'attached.txt',
136
+ })
137
+ );
138
+ await connection.downloads.idle();
139
+
140
+ assert.strictEqual(
141
+ await fs.readFile(path.join(directory.path, 'attached.txt'), 'utf8'),
142
+ 'hello'
143
+ );
144
+ await connection.downloads.dispose();
145
+ });
146
+
147
+ it('should leave a connection unmanaged when downloads were not requested', async () => {
148
+ const context = createFakeContext();
149
+ context.pages = () => [pageOf(context)];
150
+
151
+ const connection = await connectBrowserWithDependencies(
152
+ { engine: 'playwright', cdpEndpoint: 'http://127.0.0.1:9222' },
153
+ {
154
+ loadPlaywright: async () => ({
155
+ chromium: {
156
+ connectOverCDP: async () => ({ contexts: () => [context] }),
157
+ },
158
+ }),
159
+ }
160
+ );
161
+
162
+ assert.strictEqual(connection.downloads, null);
163
+ });
164
+
165
+ describe('commander.configureDownloads', () => {
166
+ it('should expose a manager the caller already has', () => {
167
+ const context = createFakeContext();
168
+ const manager = { directory: directory.path };
169
+ const commander = commanderOver(context, manager);
170
+
171
+ assert.strictEqual(commander.downloads, manager);
172
+ });
173
+
174
+ it('should attach a manager to a commander built without one', async () => {
175
+ const context = createFakeContext();
176
+ const commander = commanderOver(context);
177
+ assert.strictEqual(commander.downloads, null);
178
+
179
+ const manager = await commander.configureDownloads({
180
+ directory: directory.path,
181
+ });
182
+
183
+ assert.strictEqual(commander.downloads, manager);
184
+ await context.emitDownload(
185
+ await makeFakeDownload({
186
+ dir: directory.path,
187
+ suggestedFilename: 'configured.txt',
188
+ })
189
+ );
190
+ await manager.idle();
191
+
192
+ assert.strictEqual(
193
+ path.basename(manager.list()[0].path),
194
+ 'configured.txt'
195
+ );
196
+ await manager.dispose();
197
+ });
198
+
199
+ it('should keep downloaded files when the commander is destroyed', async () => {
200
+ // Persistence is the point: destroying the commander stops observation,
201
+ // it does not take the user's file away.
202
+ const context = createFakeContext();
203
+ const commander = commanderOver(context);
204
+ await commander.configureDownloads({ directory: directory.path });
205
+
206
+ await context.emitDownload(
207
+ await makeFakeDownload({
208
+ dir: directory.path,
209
+ suggestedFilename: 'kept.txt',
210
+ })
211
+ );
212
+ await commander.destroy();
213
+
214
+ assert.strictEqual(
215
+ await fs.readFile(path.join(directory.path, 'kept.txt'), 'utf8'),
216
+ 'hello'
217
+ );
218
+ });
219
+ });
220
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ import {
8
+ ARTIFACT_DIRECTORY_MODE,
9
+ prepareDownloadDirectory,
10
+ resolveDownloadDirectory,
11
+ } from '../../../src/downloads/destination.js';
12
+ import { useTempDownloadDirectory } from '../../helpers/download-fixtures.js';
13
+ import { assertMode, HAS_POSIX_MODES } from '../../helpers/file-modes.js';
14
+
15
+ describe('download destination (issue #88)', () => {
16
+ const directory = useTempDownloadDirectory();
17
+
18
+ describe('resolveDownloadDirectory', () => {
19
+ it('should resolve the temporary preset under the system temp directory', async () => {
20
+ const resolved = await resolveDownloadDirectory('temporary');
21
+
22
+ assert.strictEqual(
23
+ resolved,
24
+ path.join(os.tmpdir(), 'browser-commander-downloads')
25
+ );
26
+ });
27
+
28
+ it('should resolve the user preset to an absolute path', async () => {
29
+ const resolved = await resolveDownloadDirectory('user-downloads');
30
+
31
+ assert.ok(path.isAbsolute(resolved));
32
+ });
33
+
34
+ it('should default to the user preset', async () => {
35
+ assert.strictEqual(
36
+ await resolveDownloadDirectory(),
37
+ await resolveDownloadDirectory('user-downloads')
38
+ );
39
+ });
40
+
41
+ it('should accept an absolute custom path', async () => {
42
+ assert.strictEqual(
43
+ await resolveDownloadDirectory(directory.path),
44
+ directory.path
45
+ );
46
+ });
47
+
48
+ it('should refuse a relative path rather than guess a base for it', async () => {
49
+ await assert.rejects(
50
+ resolveDownloadDirectory('./downloads'),
51
+ /must be absolute/
52
+ );
53
+ });
54
+
55
+ it('should refuse a setting that is neither a path nor a preset', async () => {
56
+ await assert.rejects(resolveDownloadDirectory(42), TypeError);
57
+ await assert.rejects(resolveDownloadDirectory(''), TypeError);
58
+ });
59
+ });
60
+
61
+ describe('prepareDownloadDirectory', () => {
62
+ it('should create the directory owner-only before any download runs', async () => {
63
+ const nested = path.join(directory.path, 'deep', 'downloads');
64
+
65
+ assert.strictEqual(await prepareDownloadDirectory(nested), nested);
66
+
67
+ const stat = await fs.stat(nested);
68
+ assert.ok(stat.isDirectory());
69
+ await assertMode(nested, ARTIFACT_DIRECTORY_MODE);
70
+ });
71
+
72
+ it('should leave no probe file behind', async () => {
73
+ await prepareDownloadDirectory(directory.path);
74
+
75
+ assert.deepStrictEqual(await fs.readdir(directory.path), []);
76
+ });
77
+
78
+ it(
79
+ 'should report an unwritable directory before the first download',
80
+ {
81
+ // Root may write into any directory, so the mode bits prove nothing
82
+ // there - and on Windows there are no mode bits to take away, since
83
+ // `chmod` only toggles a read-only flag that directories ignore.
84
+ skip:
85
+ (process.getuid?.() === 0 && 'running as root') ||
86
+ (!HAS_POSIX_MODES && 'Windows has no directory mode bits'),
87
+ },
88
+ async () => {
89
+ // Issue #88: the failure has to surface at configuration time, because a
90
+ // permission error discovered after a click looks like a missing file.
91
+ const readOnly = path.join(directory.path, 'read-only');
92
+ await fs.mkdir(readOnly);
93
+ await fs.chmod(readOnly, 0o500);
94
+
95
+ await assert.rejects(
96
+ prepareDownloadDirectory(readOnly),
97
+ /is not writable/
98
+ );
99
+
100
+ await fs.chmod(readOnly, 0o700);
101
+ }
102
+ );
103
+
104
+ it('should report a directory it cannot create', async () => {
105
+ const blocker = path.join(directory.path, 'blocker');
106
+ await fs.writeFile(blocker, 'not a directory');
107
+
108
+ await assert.rejects(
109
+ prepareDownloadDirectory(path.join(blocker, 'downloads')),
110
+ /could not be created/
111
+ );
112
+ });
113
+ });
114
+ });
@@ -0,0 +1,354 @@
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 ignore progress for a download it never saw start', async () => {
342
+ const session = createFakeCdpSession();
343
+ manager = await cdpManagerOver(session);
344
+
345
+ session.emit('Browser.downloadProgress', {
346
+ guid: 'unknown',
347
+ state: 'completed',
348
+ });
349
+ await yieldToEngine();
350
+
351
+ assert.deepStrictEqual(manager.list(), []);
352
+ });
353
+ });
354
+ });