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,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
|
+
);
|