browser-commander 0.17.2 → 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 (66) hide show
  1. package/.jscpd-baseline.json +4 -17
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +84 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +4 -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 +453 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +188 -0
  19. package/src/downloads/store.js +256 -0
  20. package/src/exports.js +72 -0
  21. package/src/factory.js +55 -0
  22. package/src/interactions/click-activation.js +317 -0
  23. package/src/interactions/click-result.js +108 -0
  24. package/src/interactions/click.js +766 -352
  25. package/src/tests/index.js +75 -11
  26. package/src/tests/tracing.js +236 -0
  27. package/src/traces/bundle.js +323 -0
  28. package/src/traces/observers.js +246 -0
  29. package/src/traces/page-capture.js +365 -0
  30. package/src/traces/reader.js +226 -0
  31. package/src/traces/recorder.js +437 -0
  32. package/src/traces/redaction.js +231 -0
  33. package/src/traces/schema.js +165 -0
  34. package/src/traces/viewer.js +302 -0
  35. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  36. package/tests/e2e/downloads.e2e.test.js +188 -0
  37. package/tests/e2e/traces.e2e.test.js +457 -0
  38. package/tests/helpers/click-fixtures.js +62 -0
  39. package/tests/helpers/download-fixtures.js +125 -0
  40. package/tests/helpers/download-server.js +109 -0
  41. package/tests/helpers/e2e-browser.js +67 -0
  42. package/tests/helpers/file-modes.js +31 -0
  43. package/tests/helpers/fixture-server.js +56 -0
  44. package/tests/helpers/mocks.js +63 -66
  45. package/tests/helpers/readiness-server.js +96 -0
  46. package/tests/helpers/temp-directory.js +44 -0
  47. package/tests/helpers/trace-fixtures.js +198 -0
  48. package/tests/helpers/trace-server.js +117 -0
  49. package/tests/unit/core/dialog-manager.test.js +118 -10
  50. package/tests/unit/core/navigation-manager.test.js +162 -0
  51. package/tests/unit/core/readiness.test.js +517 -0
  52. package/tests/unit/downloads/attach.test.js +220 -0
  53. package/tests/unit/downloads/destination.test.js +114 -0
  54. package/tests/unit/downloads/manager.test.js +354 -0
  55. package/tests/unit/downloads/naming.test.js +174 -0
  56. package/tests/unit/downloads/store.test.js +215 -0
  57. package/tests/unit/interactions/click-activation.test.js +283 -0
  58. package/tests/unit/interactions/click-result.test.js +90 -0
  59. package/tests/unit/interactions/click.test.js +409 -200
  60. package/tests/unit/tests/tracing.test.js +299 -0
  61. package/tests/unit/traces/bundle.test.js +260 -0
  62. package/tests/unit/traces/reader.test.js +208 -0
  63. package/tests/unit/traces/recorder.test.js +562 -0
  64. package/tests/unit/traces/redaction.test.js +170 -0
  65. package/tests/unit/traces/schema.test.js +92 -0
  66. package/tests/unit/traces/viewer.test.js +289 -0
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Real-browser tests for issue #89.
3
+ *
4
+ * The three repros in that issue are all about claims the library made
5
+ * without checking: a page reported ready after the wait failed, a no-op
6
+ * button reported as verified, and `noAutoScroll` scrolling the page anyway.
7
+ * Only a real browser can disprove the last one, so these run against
8
+ * Chromium and a local fixture server.
9
+ *
10
+ * Run with: npm run test:e2e:click-readiness
11
+ */
12
+
13
+ import { describe, it, before, after } from 'node:test';
14
+ import assert from 'node:assert';
15
+
16
+ import { startFixtureServer } from '../helpers/readiness-server.js';
17
+
18
+ const VIEWPORT = { width: 800, height: 600 };
19
+
20
+ describe(
21
+ 'E2E - truthful readiness and click results (issue #89)',
22
+ { skip: !process.env.RUN_E2E, timeout: 120000 },
23
+ () => {
24
+ let browser;
25
+ let context;
26
+ let page;
27
+ let commander;
28
+ let server;
29
+
30
+ before(async () => {
31
+ const { chromium } = await import('playwright');
32
+ const { createCommander } = await import('../../src/index.js');
33
+
34
+ server = await startFixtureServer();
35
+ browser = await chromium.launch({
36
+ headless: process.env.HEADLESS !== 'false',
37
+ });
38
+ context = await browser.newContext({ viewport: VIEWPORT });
39
+ page = await context.newPage();
40
+ commander = createCommander({ page, engine: 'playwright' });
41
+ });
42
+
43
+ after(async () => {
44
+ await browser?.close();
45
+ await server?.close();
46
+ });
47
+
48
+ /** Load a fixture page and reset the scroll position. */
49
+ const open = async (path) => {
50
+ await page.goto(`${server.baseUrl}${path}`, {
51
+ waitUntil: 'domcontentloaded',
52
+ });
53
+ await page.evaluate(() => window.scrollTo(0, 0));
54
+ };
55
+
56
+ const scrollY = () => page.evaluate(() => window.scrollY);
57
+ const clicks = () => page.evaluate(() => window.__clicks);
58
+
59
+ /**
60
+ * Open a fixture page and click one of its elements.
61
+ *
62
+ * @param {string} path - Fixture page path
63
+ * @param {string} selector - Element to click
64
+ * @param {Object} options - Click options passed straight through
65
+ * @returns {Promise<Object>} Click result
66
+ */
67
+ const clickOn = async (path, selector, options) => {
68
+ await open(path);
69
+ return commander.clickElement({
70
+ locatorOrElement: page.locator(selector),
71
+ ...options,
72
+ });
73
+ };
74
+
75
+ /**
76
+ * Wait for readiness and report how long the wait actually took.
77
+ *
78
+ * @param {Object} options - Options for `waitForReady`
79
+ * @returns {Promise<{result: Object, elapsed: number}>} Outcome and duration
80
+ */
81
+ const timeReadiness = async (options) => {
82
+ const started = Date.now();
83
+ const result = await commander.waitForReady(options);
84
+ return { result, elapsed: Date.now() - started };
85
+ };
86
+
87
+ describe("scroll: 'none'", () => {
88
+ it('does not scroll the page when the target is already visible', async () => {
89
+ await open('/far-below-fold');
90
+ // Put the target on screen without the click doing it for us.
91
+ await page.evaluate(() =>
92
+ document.getElementById('target').scrollIntoView({ block: 'center' })
93
+ );
94
+ const before = await scrollY();
95
+
96
+ const result = await commander.clickElement({
97
+ locatorOrElement: page.locator('#target'),
98
+ scroll: 'none',
99
+ verify: false,
100
+ });
101
+
102
+ assert.strictEqual(result.dispatched, true, result.reason);
103
+ assert.strictEqual(await clicks(), 1);
104
+ assert.strictEqual(
105
+ await scrollY(),
106
+ before,
107
+ 'scroll: none must not move the page'
108
+ );
109
+ });
110
+
111
+ it('fails clearly instead of scrolling to an off-screen target', async () => {
112
+ // Regression test for issue #89 repro 3: this used to map to
113
+ // Playwright's `force: true`, which skips actionability checks but
114
+ // still scrolls, so the reported "no scroll" click scrolled to 3911.
115
+ const result = await clickOn('/far-below-fold', '#target', {
116
+ scroll: 'none',
117
+ verify: false,
118
+ });
119
+
120
+ assert.strictEqual(result.status, 'failed');
121
+ assert.strictEqual(result.dispatched, false);
122
+ assert.match(result.reason, /viewport|obscure/i);
123
+ assert.strictEqual(await scrollY(), 0, 'the page must not have moved');
124
+ assert.strictEqual(await clicks(), 0);
125
+ });
126
+
127
+ it('treats the legacy noAutoScroll flag the same way', async () => {
128
+ const result = await clickOn('/far-below-fold', '#target', {
129
+ noAutoScroll: true,
130
+ verify: false,
131
+ });
132
+
133
+ assert.strictEqual(result.dispatched, false);
134
+ assert.strictEqual(await scrollY(), 0);
135
+ });
136
+
137
+ it("scroll: 'auto' still scrolls, as it always did", async () => {
138
+ const result = await clickOn('/far-below-fold', '#target', {
139
+ scroll: 'auto',
140
+ verify: false,
141
+ });
142
+
143
+ assert.strictEqual(result.dispatched, true, result.reason);
144
+ assert.ok((await scrollY()) > 0, 'scroll: auto is allowed to scroll');
145
+ assert.strictEqual(await clicks(), 1);
146
+ });
147
+
148
+ it("scroll: 'preserve' restores the position it started from", async () => {
149
+ await clickOn('/far-below-fold', '#target', {
150
+ scroll: 'preserve',
151
+ verify: false,
152
+ });
153
+
154
+ assert.strictEqual(await clicks(), 1);
155
+ assert.strictEqual(await scrollY(), 0);
156
+ });
157
+ });
158
+
159
+ describe('click verification', () => {
160
+ it('reports unverified for a button that does nothing', async () => {
161
+ // Regression test for issue #89 repro 2: "element still connected"
162
+ // used to be reported as `verified: true`.
163
+ const result = await clickOn('/no-op', '#noop', { verify: true });
164
+
165
+ assert.strictEqual(await clicks(), 1, 'the click must still happen');
166
+ assert.strictEqual(result.dispatched, true);
167
+ assert.strictEqual(result.status, 'unverified');
168
+ assert.strictEqual(result.effect, 'not-observed');
169
+ assert.strictEqual(result.verified, false);
170
+ assert.ok(result.evidence.length > 0, 'a verdict needs evidence');
171
+ });
172
+
173
+ it('confirms a click that actually changed the element', async () => {
174
+ const result = await clickOn('/far-below-fold', '#target', {
175
+ scroll: 'auto',
176
+ verify: true,
177
+ });
178
+
179
+ assert.strictEqual(result.status, 'succeeded');
180
+ assert.strictEqual(result.effect, 'confirmed');
181
+ assert.strictEqual(result.verified, true);
182
+ });
183
+
184
+ it('carries an action id and an elapsed time on every result', async () => {
185
+ const result = await clickOn('/no-op', '#noop', { verify: true });
186
+
187
+ assert.strictEqual(typeof result.actionId, 'string');
188
+ assert.ok(result.actionId.length > 0);
189
+ assert.ok(Number.isFinite(result.elapsedMs));
190
+ });
191
+
192
+ it('does not claim a navigating click was verified by the navigation', async () => {
193
+ const result = await clickOn('/navigates', '#go', { verify: true });
194
+
195
+ await page.waitForSelector('#arrived');
196
+ // The click did navigate, but nothing proved the click caused it, so
197
+ // the result must not be dressed up as a confirmed effect.
198
+ assert.notStrictEqual(result.effect, 'confirmed');
199
+ assert.ok(
200
+ ['unverified', 'interrupted', 'succeeded'].includes(result.status),
201
+ `unexpected status ${result.status}`
202
+ );
203
+ assert.strictEqual(typeof result.actionId, 'string');
204
+ });
205
+ });
206
+
207
+ describe('readiness deadlines', () => {
208
+ it('reports timed_out instead of ready when the network never settles', async () => {
209
+ // Regression test for issue #89 repro 1: the wait failed and the page
210
+ // was announced as ready anyway.
211
+ await open('/never-idle');
212
+
213
+ const { result, elapsed } = await timeReadiness({
214
+ timeout: 1500,
215
+ reason: 'e2e deadline check',
216
+ });
217
+
218
+ assert.strictEqual(result.ready, false);
219
+ assert.strictEqual(result.status, 'timed_out');
220
+ assert.ok(
221
+ elapsed < 6000,
222
+ `the wait took ${elapsed}ms for a 1500ms budget`
223
+ );
224
+ assert.ok(result.evidence.length > 0);
225
+ });
226
+
227
+ it('never spends more than the budget it was given', async () => {
228
+ await open('/never-idle');
229
+
230
+ const { result, elapsed } = await timeReadiness({ timeout: 200 });
231
+
232
+ assert.strictEqual(result.ready, false);
233
+ assert.ok(
234
+ elapsed < 3000,
235
+ `a 200ms budget must not become ${elapsed}ms`
236
+ );
237
+ });
238
+ });
239
+ }
240
+ );
@@ -0,0 +1,188 @@
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
+ PDF_BODY,
24
+ REPORT_BODY,
25
+ startDownloadServer,
26
+ } from '../helpers/download-server.js';
27
+
28
+ const ENGINES = ['playwright', 'puppeteer'];
29
+
30
+ /**
31
+ * Run the managed-download expectations against one engine.
32
+ *
33
+ * @param {string} engine - 'playwright' or 'puppeteer'
34
+ * @returns {void}
35
+ */
36
+ function describeDownloads(engine) {
37
+ describe(`E2E - managed downloads with ${engine} (issue #88)`, () => {
38
+ let server;
39
+ let browser;
40
+ let page;
41
+ let downloads;
42
+ let directory;
43
+ let cleanup;
44
+
45
+ before(async () => {
46
+ server = await startDownloadServer();
47
+ directory = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-e2e-downloads-'));
48
+ ({ browser, page, downloads, cleanup } = await launchE2EBrowser({
49
+ engine,
50
+ downloadDirectory: directory,
51
+ }));
52
+ });
53
+
54
+ // A download the browser decides to display instead - a PDF it can render -
55
+ // leaves the page somewhere else, so every test starts from the fixture.
56
+ beforeEach(async () => {
57
+ await page.goto(`${server.baseUrl}/`);
58
+ });
59
+
60
+ after(async () => {
61
+ await downloads?.dispose();
62
+ await cleanup?.();
63
+ await server?.close();
64
+ await fs.rm(directory, { recursive: true, force: true });
65
+ });
66
+
67
+ /**
68
+ * Click an element and wait for the download it triggers.
69
+ *
70
+ * @param {string} selector - Element to click
71
+ * @param {Object} [options] - Capture options
72
+ * @returns {Promise<Object>} The settled artifact
73
+ */
74
+ const captureClickOn = async (selector, options = {}) =>
75
+ downloads.capture({
76
+ action: () => page.click(selector),
77
+ timeout: 20000,
78
+ ...options,
79
+ });
80
+
81
+ it('should save a file the browser downloaded from a click', async () => {
82
+ const artifact = await captureClickOn('#link');
83
+
84
+ assert.strictEqual(artifact.state, 'completed');
85
+ assert.strictEqual(path.basename(artifact.path), 'report.pdf');
86
+ assert.strictEqual(await fs.readFile(artifact.path, 'utf8'), REPORT_BODY);
87
+ assert.strictEqual(
88
+ artifact.checksum,
89
+ createHash('sha256').update(REPORT_BODY).digest('hex')
90
+ );
91
+ assert.strictEqual(artifact.bytes, Buffer.byteLength(REPORT_BODY));
92
+ });
93
+
94
+ it('should save a file the page built from a blob', async () => {
95
+ const artifact = await captureClickOn('#blob');
96
+
97
+ assert.strictEqual(path.basename(artifact.path), 'generated.txt');
98
+ assert.strictEqual(await fs.readFile(artifact.path, 'utf8'), REPORT_BODY);
99
+ });
100
+
101
+ it('should save a navigation that turned into a download', async () => {
102
+ const artifact = await captureClickOn('#navigating');
103
+
104
+ assert.strictEqual(artifact.state, 'completed');
105
+ assert.strictEqual(path.basename(artifact.path), 'statement.pdf');
106
+ });
107
+
108
+ it('should give an unnamed download an extension from its bytes', async () => {
109
+ const artifact = await captureClickOn('#unnamed');
110
+
111
+ assert.ok(
112
+ artifact.path.endsWith('.pdf'),
113
+ `expected a .pdf suffix, got ${artifact.path}`
114
+ );
115
+ assert.strictEqual(await fs.readFile(artifact.path, 'utf8'), PDF_BODY);
116
+ });
117
+
118
+ it('should let the caller name a download as it is captured', async () => {
119
+ const artifact = await captureClickOn('#link', {
120
+ filename: 'named-by-the-caller.pdf',
121
+ });
122
+
123
+ assert.strictEqual(
124
+ path.basename(artifact.path),
125
+ 'named-by-the-caller.pdf'
126
+ );
127
+ });
128
+
129
+ it('should report a transfer the server aborted, with no file left behind', async () => {
130
+ const failures = [];
131
+ downloads.on('failed', (artifact) => failures.push(artifact));
132
+ downloads.on('cancelled', (artifact) => failures.push(artifact));
133
+
134
+ // A download that never produced bytes is not a result: the awaited
135
+ // capture fails with the engine's own words, because "a download UI
136
+ // entry is not completion evidence" (issue #88).
137
+ await assert.rejects(captureClickOn('#broken'), /dl-\d+/);
138
+
139
+ assert.strictEqual(failures.length, 1);
140
+ assert.ok(failures[0].failure, 'the engine reason should be preserved');
141
+ const entries = await fs.readdir(directory);
142
+ assert.ok(
143
+ !entries.includes('broken.pdf'),
144
+ `a failed download must not appear under its final name: ${entries}`
145
+ );
146
+ assert.ok(
147
+ !entries.some((entry) => entry.endsWith('.partial')),
148
+ `no partial file should be left behind: ${entries}`
149
+ );
150
+ });
151
+
152
+ it('should manage a download the automation never started', async () => {
153
+ // The closest a test can get to a person downloading a file: a target
154
+ // the automation did not create, downloading through the browser rather
155
+ // than through the engine's download API. Issue #88 asks for "one
156
+ // lifecycle for automated *and* manual downloads".
157
+ const session = await openBrowserCdpSession({ engine, browser, page });
158
+ const captured = downloads.capture({ timeout: 20000 });
159
+ await session.send('Target.createTarget', {
160
+ url: `${server.baseUrl}/file/manual.pdf`,
161
+ });
162
+
163
+ const artifact = await captured;
164
+ assert.strictEqual(path.basename(artifact.path), 'manual.pdf');
165
+ assert.strictEqual(await fs.readFile(artifact.path, 'utf8'), REPORT_BODY);
166
+ });
167
+
168
+ it('should keep every saved file after the browser closes', async () => {
169
+ const before = (await fs.readdir(directory)).sort();
170
+ await downloads.dispose();
171
+ await browser.close();
172
+ browser = undefined;
173
+
174
+ assert.deepStrictEqual((await fs.readdir(directory)).sort(), before);
175
+ assert.ok(before.length >= 5, `expected saved files, got ${before}`);
176
+ });
177
+ });
178
+ }
179
+
180
+ describe(
181
+ 'E2E - managed downloads (issue #88)',
182
+ { skip: !process.env.RUN_E2E, timeout: 180000 },
183
+ () => {
184
+ for (const engine of ENGINES) {
185
+ describeDownloads(engine);
186
+ }
187
+ }
188
+ );