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.
Files changed (74) hide show
  1. package/.jscpd-baseline.json +12 -17
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +132 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +5 -1
  6. package/src/bindings.js +13 -1
  7. package/src/browser/connector.js +15 -3
  8. package/src/browser/launcher.js +14 -2
  9. package/src/browser/navigation.js +63 -0
  10. package/src/core/dialog-manager.js +48 -0
  11. package/src/core/navigation-manager.js +83 -87
  12. package/src/core/navigation-readiness.js +133 -0
  13. package/src/core/readiness.js +478 -0
  14. package/src/downloads/attach.js +58 -0
  15. package/src/downloads/destination.js +125 -0
  16. package/src/downloads/manager.js +476 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +238 -0
  19. package/src/downloads/staging.js +146 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +86 -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 +338 -0
  29. package/src/traces/deadline.js +33 -0
  30. package/src/traces/identity.js +127 -0
  31. package/src/traces/init-script.js +95 -0
  32. package/src/traces/links.js +623 -0
  33. package/src/traces/mutation-stream.js +232 -0
  34. package/src/traces/observers.js +272 -0
  35. package/src/traces/page-capture.js +601 -0
  36. package/src/traces/reader.js +226 -0
  37. package/src/traces/recorder.js +454 -0
  38. package/src/traces/redaction.js +231 -0
  39. package/src/traces/schema.js +229 -0
  40. package/src/traces/viewer.js +448 -0
  41. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  42. package/tests/e2e/downloads.e2e.test.js +235 -0
  43. package/tests/e2e/traces.e2e.test.js +742 -0
  44. package/tests/helpers/click-fixtures.js +62 -0
  45. package/tests/helpers/download-fixtures.js +125 -0
  46. package/tests/helpers/download-server.js +129 -0
  47. package/tests/helpers/e2e-browser.js +67 -0
  48. package/tests/helpers/file-modes.js +31 -0
  49. package/tests/helpers/fixture-server.js +56 -0
  50. package/tests/helpers/mocks.js +63 -66
  51. package/tests/helpers/readiness-server.js +96 -0
  52. package/tests/helpers/temp-directory.js +44 -0
  53. package/tests/helpers/trace-fixtures.js +274 -0
  54. package/tests/helpers/trace-server.js +173 -0
  55. package/tests/unit/core/dialog-manager.test.js +118 -10
  56. package/tests/unit/core/navigation-manager.test.js +162 -0
  57. package/tests/unit/core/readiness.test.js +517 -0
  58. package/tests/unit/downloads/attach.test.js +220 -0
  59. package/tests/unit/downloads/destination.test.js +114 -0
  60. package/tests/unit/downloads/manager.test.js +485 -0
  61. package/tests/unit/downloads/naming.test.js +174 -0
  62. package/tests/unit/downloads/staging.test.js +117 -0
  63. package/tests/unit/downloads/store.test.js +215 -0
  64. package/tests/unit/interactions/click-activation.test.js +283 -0
  65. package/tests/unit/interactions/click-result.test.js +90 -0
  66. package/tests/unit/interactions/click.test.js +409 -200
  67. package/tests/unit/tests/tracing.test.js +299 -0
  68. package/tests/unit/traces/bundle.test.js +260 -0
  69. package/tests/unit/traces/links.test.js +557 -0
  70. package/tests/unit/traces/reader.test.js +208 -0
  71. package/tests/unit/traces/recorder.test.js +730 -0
  72. package/tests/unit/traces/redaction.test.js +170 -0
  73. package/tests/unit/traces/schema.test.js +92 -0
  74. package/tests/unit/traces/viewer.test.js +294 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Real-browser tests for issue #88.
3
+ *
4
+ * The managed lifecycle only means something against a real engine: a browser
5
+ * decides for itself when a download starts, where it stages the bytes and
6
+ * whether a transfer failed. These tests run the same expectations against
7
+ * both engines, because the issue asks for a manager that behaves identically
8
+ * whichever one is driving.
9
+ *
10
+ * Run with: npm run test:e2e:downloads
11
+ */
12
+
13
+ import { describe, it, before, beforeEach, after } from 'node:test';
14
+ import assert from 'node:assert';
15
+ import { createHash } from 'node:crypto';
16
+ import fs from 'node:fs/promises';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ import { openBrowserCdpSession } from '../../src/downloads/sources.js';
21
+ import { launchE2EBrowser } from '../helpers/e2e-browser.js';
22
+ import {
23
+ BLOB_PDF_BODY,
24
+ PDF_BODY,
25
+ REPORT_BODY,
26
+ startDownloadServer,
27
+ } from '../helpers/download-server.js';
28
+
29
+ const ENGINES = ['playwright', 'puppeteer'];
30
+
31
+ /**
32
+ * Run the managed-download expectations against one engine.
33
+ *
34
+ * @param {string} engine - 'playwright' or 'puppeteer'
35
+ * @returns {void}
36
+ */
37
+ function describeDownloads(engine) {
38
+ describe(`E2E - managed downloads with ${engine} (issue #88)`, () => {
39
+ let server;
40
+ let browser;
41
+ let page;
42
+ let downloads;
43
+ let directory;
44
+ let cleanup;
45
+
46
+ before(async () => {
47
+ server = await startDownloadServer();
48
+ directory = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-e2e-downloads-'));
49
+ ({ browser, page, downloads, cleanup } = await launchE2EBrowser({
50
+ engine,
51
+ downloadDirectory: directory,
52
+ }));
53
+ });
54
+
55
+ // A download the browser decides to display instead - a PDF it can render -
56
+ // leaves the page somewhere else, so every test starts from the fixture.
57
+ beforeEach(async () => {
58
+ await page.goto(`${server.baseUrl}/`);
59
+ });
60
+
61
+ after(async () => {
62
+ await downloads?.dispose();
63
+ await cleanup?.();
64
+ await server?.close();
65
+ await fs.rm(directory, { recursive: true, force: true });
66
+ });
67
+
68
+ /**
69
+ * Click an element and wait for the download it triggers.
70
+ *
71
+ * @param {string} selector - Element to click
72
+ * @param {Object} [options] - Capture options
73
+ * @returns {Promise<Object>} The settled artifact
74
+ */
75
+ const captureClickOn = async (selector, options = {}) =>
76
+ downloads.capture({
77
+ action: () => page.click(selector),
78
+ timeout: 20000,
79
+ ...options,
80
+ });
81
+
82
+ it('should save a file the browser downloaded from a click', async () => {
83
+ const artifact = await captureClickOn('#link');
84
+
85
+ assert.strictEqual(artifact.state, 'completed');
86
+ assert.strictEqual(path.basename(artifact.path), 'report.pdf');
87
+ assert.strictEqual(await fs.readFile(artifact.path, 'utf8'), REPORT_BODY);
88
+ assert.strictEqual(
89
+ artifact.checksum,
90
+ createHash('sha256').update(REPORT_BODY).digest('hex')
91
+ );
92
+ assert.strictEqual(artifact.bytes, Buffer.byteLength(REPORT_BODY));
93
+ });
94
+
95
+ it('should save a file the page built from a blob', async () => {
96
+ const artifact = await captureClickOn('#blob');
97
+
98
+ assert.strictEqual(path.basename(artifact.path), 'generated.txt');
99
+ assert.strictEqual(await fs.readFile(artifact.path, 'utf8'), REPORT_BODY);
100
+ });
101
+
102
+ it('should not publish a completed download before its bytes are readable', async () => {
103
+ // Issue #92: Chromium reports `Browser.downloadProgress` with
104
+ // `state: "completed"` before the GUID-named staging file is readable,
105
+ // and the manager then failed with
106
+ // `ENOENT ... .browser-commander-staging/<guid>`. A PDF the page builds
107
+ // itself is the shape the issue reported.
108
+ const completed = [];
109
+ const failures = [];
110
+ downloads.on('completed', (artifact) => completed.push(artifact));
111
+ downloads.on('failed', (artifact) => failures.push(artifact));
112
+
113
+ const artifact = await captureClickOn('#blob-pdf');
114
+
115
+ assert.deepStrictEqual(failures, []);
116
+ assert.strictEqual(artifact.state, 'completed');
117
+ assert.strictEqual(path.basename(artifact.path), 'generated.pdf');
118
+ assert.strictEqual(
119
+ await fs.readFile(artifact.path, 'utf8'),
120
+ BLOB_PDF_BODY
121
+ );
122
+ assert.strictEqual(artifact.bytes, Buffer.byteLength(BLOB_PDF_BODY));
123
+
124
+ const published = completed.filter(
125
+ (entry) => entry.path === artifact.path
126
+ );
127
+ assert.strictEqual(
128
+ published.length,
129
+ 1,
130
+ `exactly one completed artifact should be published: ${published.length}`
131
+ );
132
+
133
+ const staged = await fs
134
+ .readdir(path.join(directory, '.browser-commander-staging'))
135
+ .catch(() => []);
136
+ assert.deepStrictEqual(
137
+ staged,
138
+ [],
139
+ `no staged file should survive a saved download: ${staged}`
140
+ );
141
+ const entries = await fs.readdir(directory);
142
+ assert.ok(
143
+ !entries.some((entry) => entry.endsWith('.partial')),
144
+ `no partial file should be left behind: ${entries}`
145
+ );
146
+ });
147
+
148
+ it('should save a navigation that turned into a download', async () => {
149
+ const artifact = await captureClickOn('#navigating');
150
+
151
+ assert.strictEqual(artifact.state, 'completed');
152
+ assert.strictEqual(path.basename(artifact.path), 'statement.pdf');
153
+ });
154
+
155
+ it('should give an unnamed download an extension from its bytes', async () => {
156
+ const artifact = await captureClickOn('#unnamed');
157
+
158
+ assert.ok(
159
+ artifact.path.endsWith('.pdf'),
160
+ `expected a .pdf suffix, got ${artifact.path}`
161
+ );
162
+ assert.strictEqual(await fs.readFile(artifact.path, 'utf8'), PDF_BODY);
163
+ });
164
+
165
+ it('should let the caller name a download as it is captured', async () => {
166
+ const artifact = await captureClickOn('#link', {
167
+ filename: 'named-by-the-caller.pdf',
168
+ });
169
+
170
+ assert.strictEqual(
171
+ path.basename(artifact.path),
172
+ 'named-by-the-caller.pdf'
173
+ );
174
+ });
175
+
176
+ it('should report a transfer the server aborted, with no file left behind', async () => {
177
+ const failures = [];
178
+ downloads.on('failed', (artifact) => failures.push(artifact));
179
+ downloads.on('cancelled', (artifact) => failures.push(artifact));
180
+
181
+ // A download that never produced bytes is not a result: the awaited
182
+ // capture fails with the engine's own words, because "a download UI
183
+ // entry is not completion evidence" (issue #88).
184
+ await assert.rejects(captureClickOn('#broken'), /dl-\d+/);
185
+
186
+ assert.strictEqual(failures.length, 1);
187
+ assert.ok(failures[0].failure, 'the engine reason should be preserved');
188
+ const entries = await fs.readdir(directory);
189
+ assert.ok(
190
+ !entries.includes('broken.pdf'),
191
+ `a failed download must not appear under its final name: ${entries}`
192
+ );
193
+ assert.ok(
194
+ !entries.some((entry) => entry.endsWith('.partial')),
195
+ `no partial file should be left behind: ${entries}`
196
+ );
197
+ });
198
+
199
+ it('should manage a download the automation never started', async () => {
200
+ // The closest a test can get to a person downloading a file: a target
201
+ // the automation did not create, downloading through the browser rather
202
+ // than through the engine's download API. Issue #88 asks for "one
203
+ // lifecycle for automated *and* manual downloads".
204
+ const session = await openBrowserCdpSession({ engine, browser, page });
205
+ const captured = downloads.capture({ timeout: 20000 });
206
+ await session.send('Target.createTarget', {
207
+ url: `${server.baseUrl}/file/manual.pdf`,
208
+ });
209
+
210
+ const artifact = await captured;
211
+ assert.strictEqual(path.basename(artifact.path), 'manual.pdf');
212
+ assert.strictEqual(await fs.readFile(artifact.path, 'utf8'), REPORT_BODY);
213
+ });
214
+
215
+ it('should keep every saved file after the browser closes', async () => {
216
+ const before = (await fs.readdir(directory)).sort();
217
+ await downloads.dispose();
218
+ await browser.close();
219
+ browser = undefined;
220
+
221
+ assert.deepStrictEqual((await fs.readdir(directory)).sort(), before);
222
+ assert.ok(before.length >= 5, `expected saved files, got ${before}`);
223
+ });
224
+ });
225
+ }
226
+
227
+ describe(
228
+ 'E2E - managed downloads (issue #88)',
229
+ { skip: !process.env.RUN_E2E, timeout: 180000 },
230
+ () => {
231
+ for (const engine of ENGINES) {
232
+ describeDownloads(engine);
233
+ }
234
+ }
235
+ );