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,109 @@
1
+ /**
2
+ * A tiny server for the real-browser download tests (issue #88).
3
+ *
4
+ * The issue's acceptance criteria are about downloads a mock cannot produce:
5
+ * a link the browser downloads instead of navigating to, a file built in the
6
+ * page from a blob, a navigation that turns into a download, a name the page
7
+ * only hints at, and a transfer the server aborts halfway.
8
+ */
9
+
10
+ import { sendHtml, sendNotFound, startFixtureHost } from './fixture-server.js';
11
+
12
+ /** Bytes every fixture download is expected to produce. */
13
+ export const REPORT_BODY = 'quarterly report body';
14
+
15
+ /** A body only its leading bytes identify as a PDF. */
16
+ export const PDF_BODY = `%PDF-1.7\n${REPORT_BODY}`;
17
+
18
+ /** A page offering the download shapes the issue lists. */
19
+ export const DOWNLOADS_PAGE = `<!doctype html>
20
+ <html><body style="margin:0">
21
+ <a id="link" href="/file/report.pdf" download>Download the report</a>
22
+ <a id="navigating" href="/attachment">Open the statement</a>
23
+ <a id="unnamed" href="/unnamed" download>Download without a name</a>
24
+ <a id="broken" href="/aborted" download>Download a broken file</a>
25
+ <button id="blob">Build and download</button>
26
+ <script>
27
+ document.getElementById('blob').addEventListener('click', () => {
28
+ const blob = new Blob([${JSON.stringify(REPORT_BODY)}], {
29
+ type: 'text/plain',
30
+ });
31
+ const url = URL.createObjectURL(blob);
32
+ const anchor = document.createElement('a');
33
+ anchor.href = url;
34
+ anchor.download = 'generated.txt';
35
+ document.body.appendChild(anchor);
36
+ anchor.click();
37
+ });
38
+ </script>
39
+ </body></html>`;
40
+
41
+ /**
42
+ * Answer a request for one of the fixture downloads.
43
+ *
44
+ * @param {string} path - Request path
45
+ * @param {Object} res - Node response
46
+ * @returns {boolean} Whether the request was a download
47
+ */
48
+ function serveDownload(path, res) {
49
+ if (path.startsWith('/file/')) {
50
+ res.writeHead(200, {
51
+ 'content-type': 'application/pdf',
52
+ 'content-disposition': `attachment; filename="${path.slice('/file/'.length)}"`,
53
+ });
54
+ res.end(REPORT_BODY);
55
+ return true;
56
+ }
57
+
58
+ if (path === '/attachment') {
59
+ // No `download` attribute on the link: the browser starts navigating and
60
+ // turns the navigation into a download once it reads this header.
61
+ res.writeHead(200, {
62
+ 'content-type': 'application/pdf',
63
+ 'content-disposition': 'attachment; filename="statement.pdf"',
64
+ });
65
+ res.end(REPORT_BODY);
66
+ return true;
67
+ }
68
+
69
+ if (path === '/unnamed') {
70
+ // Nothing names this file and the server declares nothing useful: only the
71
+ // bytes themselves can give it a suffix.
72
+ res.writeHead(200, { 'content-type': 'application/octet-stream' });
73
+ res.end(PDF_BODY);
74
+ return true;
75
+ }
76
+
77
+ if (path === '/aborted') {
78
+ res.writeHead(200, {
79
+ 'content-type': 'application/pdf',
80
+ 'content-length': '1000000',
81
+ 'content-disposition': 'attachment; filename="broken.pdf"',
82
+ });
83
+ res.write('partial');
84
+ res.destroy();
85
+ return true;
86
+ }
87
+
88
+ return false;
89
+ }
90
+
91
+ /**
92
+ * Start the download fixture server on an ephemeral port.
93
+ *
94
+ * @returns {Promise<{baseUrl: string, close: Function}>} Server handle
95
+ */
96
+ export async function startDownloadServer() {
97
+ return startFixtureHost((path, req, res) => {
98
+ if (serveDownload(path, res)) {
99
+ return;
100
+ }
101
+
102
+ if (path === '/') {
103
+ sendHtml(res, DOWNLOADS_PAGE);
104
+ return;
105
+ }
106
+
107
+ sendNotFound(res);
108
+ });
109
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The browser an end-to-end suite runs against (issues #87, #88).
3
+ *
4
+ * Every real-browser suite needs the same three things before it can test
5
+ * anything: a throwaway profile, the arguments a sandbox-less container
6
+ * needs, and a directory the browser may write downloads into. Starting a
7
+ * browser is not what those suites are about, so it lives here once and they
8
+ * read as the behaviour they check.
9
+ */
10
+
11
+ import fs from 'node:fs/promises';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ import { launchBrowser } from '../../src/browser/launcher.js';
16
+
17
+ /**
18
+ * Extra Chrome arguments for environments without a usable sandbox.
19
+ *
20
+ * Containers commonly forbid unprivileged user namespaces, where Chromium
21
+ * refuses to start at all; CHROME_NO_SANDBOX=true makes a suite runnable
22
+ * there without weakening how the library launches browsers for everyone else.
23
+ */
24
+ export const SANDBOX_ARGS =
25
+ process.env.CHROME_NO_SANDBOX === 'true' ? ['--no-sandbox'] : [];
26
+
27
+ /**
28
+ * Launch a real browser for one engine, with its own profile.
29
+ *
30
+ * @param {Object} options - Launch options
31
+ * @param {string} options.engine - 'playwright' or 'puppeteer'
32
+ * @param {string} [options.downloadDirectory] - Where managed downloads are kept
33
+ * @returns {Promise<{browser: Object, page: Object, downloads: Object, cleanup: Function}>} The launched browser and a `cleanup` that closes it and removes the profile
34
+ */
35
+ export async function launchE2EBrowser(options = {}) {
36
+ const { engine, downloadDirectory } = options;
37
+ const userDataDir = await fs.mkdtemp(
38
+ path.join(os.tmpdir(), 'bc-e2e-profile-')
39
+ );
40
+
41
+ const launched = await launchBrowser({
42
+ engine,
43
+ userDataDir,
44
+ headless: process.env.HEADLESS !== 'false',
45
+ slowMo: 0,
46
+ args: SANDBOX_ARGS,
47
+ ...(process.env.CHROME_PATH
48
+ ? { executablePath: process.env.CHROME_PATH }
49
+ : {}),
50
+ ...(downloadDirectory
51
+ ? { downloads: { directory: downloadDirectory } }
52
+ : {}),
53
+ });
54
+
55
+ return {
56
+ ...launched,
57
+ /**
58
+ * Close the browser and remove the profile this helper created.
59
+ *
60
+ * @returns {Promise<void>} Resolves once both are gone
61
+ */
62
+ cleanup: async () => {
63
+ await launched.browser?.close();
64
+ await fs.rm(userDataDir, { recursive: true, force: true });
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Checking owner-only permissions where the platform actually has them.
3
+ *
4
+ * Node accepts a `mode` on Windows and then ignores it: NTFS permissions are
5
+ * ACLs, not POSIX bits, and `fs.stat` reports a synthesised `0o666` whatever
6
+ * the file was created with. Asserting the mode there would be asserting
7
+ * Node's emulation rather than our code, so the permission check runs on the
8
+ * platforms that enforce it and the rest of each test runs everywhere.
9
+ */
10
+
11
+ import assert from 'node:assert';
12
+ import fs from 'node:fs/promises';
13
+
14
+ /** Whether this platform enforces POSIX mode bits. */
15
+ export const HAS_POSIX_MODES = process.platform !== 'win32';
16
+
17
+ /**
18
+ * Assert that a path carries the mode the code asked for.
19
+ *
20
+ * @param {string} target - File or directory to inspect
21
+ * @param {number} expected - Expected permission bits, e.g. `0o600`
22
+ * @returns {Promise<void>} Resolves once checked, or immediately on Windows
23
+ */
24
+ export async function assertMode(target, expected) {
25
+ if (!HAS_POSIX_MODES) {
26
+ return;
27
+ }
28
+
29
+ const { mode } = await fs.stat(target);
30
+ assert.strictEqual(mode & 0o777, expected);
31
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The bit of a fixture server that has nothing to do with the fixtures.
3
+ *
4
+ * The readiness pages (issue #89) and the download endpoints (issue #88) need
5
+ * the same thing underneath: a localhost server on a port nobody else is using,
6
+ * with a handle that shuts it down again.
7
+ */
8
+
9
+ import { createServer } from 'node:http';
10
+
11
+ /**
12
+ * Send an HTML fixture page.
13
+ *
14
+ * @param {Object} res - Node response
15
+ * @param {string} body - Page markup
16
+ * @returns {void}
17
+ */
18
+ export function sendHtml(res, body) {
19
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
20
+ res.end(body);
21
+ }
22
+
23
+ /**
24
+ * Answer a request for a fixture that does not exist.
25
+ *
26
+ * @param {Object} res - Node response
27
+ * @returns {void}
28
+ */
29
+ export function sendNotFound(res) {
30
+ res.writeHead(404, { 'content-type': 'text/plain' });
31
+ res.end('not found');
32
+ }
33
+
34
+ /**
35
+ * Start a fixture server on an ephemeral port.
36
+ *
37
+ * @param {Function} handle - Request handler, called with (path, req, res)
38
+ * @param {Function} [onClose] - Teardown to run before the server closes
39
+ * @returns {Promise<{baseUrl: string, close: Function}>} Server handle
40
+ */
41
+ export async function startFixtureHost(handle, onClose) {
42
+ const server = createServer((req, res) => {
43
+ handle(req.url.split('?')[0], req, res);
44
+ });
45
+
46
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
47
+ const { port } = server.address();
48
+
49
+ return {
50
+ baseUrl: `http://127.0.0.1:${port}`,
51
+ close: async () => {
52
+ await onClose?.();
53
+ await new Promise((resolve) => server.close(resolve));
54
+ },
55
+ };
56
+ }
@@ -2,6 +2,67 @@
2
2
  * Mock utilities for unit tests
3
3
  */
4
4
 
5
+ /**
6
+ * Members shared by the Playwright and Puppeteer page mocks.
7
+ *
8
+ * Both engines expose the same event, input and frame surface, so the mocks
9
+ * describe it once. `mouse` records its clicks because the `scroll: 'none'`
10
+ * click path dispatches real pointer input at a measured point.
11
+ *
12
+ * @param {Object} options - Configuration options
13
+ * @param {string} options.url - URL the mock page reports
14
+ * @param {Map} options.eventListeners - Listener registry shared with the page
15
+ * @returns {Object} Shared page members
16
+ */
17
+ function createSharedPageBehaviors(options) {
18
+ const { url, eventListeners } = options;
19
+
20
+ return {
21
+ mainFrame: () => ({
22
+ url: () => url,
23
+ }),
24
+ bringToFront: async () => {},
25
+ on: (event, handler) => {
26
+ if (!eventListeners.has(event)) {
27
+ eventListeners.set(event, []);
28
+ }
29
+ eventListeners.get(event).push(handler);
30
+ },
31
+ off: (event, handler) => {
32
+ const handlers = eventListeners.get(event);
33
+ if (handlers) {
34
+ const idx = handlers.indexOf(handler);
35
+ if (idx !== -1) {
36
+ handlers.splice(idx, 1);
37
+ }
38
+ }
39
+ },
40
+ emit: (event, data) => {
41
+ const handlers = eventListeners.get(event);
42
+ if (handlers) {
43
+ handlers.forEach((h) => h(data));
44
+ }
45
+ },
46
+ click: async (sel, opts = {}) => {},
47
+ type: async (sel, text, opts = {}) => {},
48
+ keyboard: {
49
+ press: async (key) => {},
50
+ type: async (text) => {},
51
+ down: async (key) => {},
52
+ up: async (key) => {},
53
+ },
54
+ mouse: {
55
+ clicks: [],
56
+ async click(x, y, opts = {}) {
57
+ this.clicks.push({ x, y, opts });
58
+ },
59
+ async move(x, y) {},
60
+ async down() {},
61
+ async up() {},
62
+ },
63
+ };
64
+ }
65
+
5
66
  /**
6
67
  * Create a mock Playwright page object
7
68
  */
@@ -122,40 +183,8 @@ export function createMockPlaywrightPage(options = {}) {
122
183
  return fn;
123
184
  }
124
185
  },
125
- mainFrame: () => ({
126
- url: () => url,
127
- }),
186
+ ...createSharedPageBehaviors({ url, eventListeners }),
128
187
  context: () => ({}),
129
- bringToFront: async () => {},
130
- on: (event, handler) => {
131
- if (!eventListeners.has(event)) {
132
- eventListeners.set(event, []);
133
- }
134
- eventListeners.get(event).push(handler);
135
- },
136
- off: (event, handler) => {
137
- const handlers = eventListeners.get(event);
138
- if (handlers) {
139
- const idx = handlers.indexOf(handler);
140
- if (idx !== -1) {
141
- handlers.splice(idx, 1);
142
- }
143
- }
144
- },
145
- emit: (event, data) => {
146
- const handlers = eventListeners.get(event);
147
- if (handlers) {
148
- handlers.forEach((h) => h(data));
149
- }
150
- },
151
- click: async (sel, opts = {}) => {},
152
- type: async (sel, text, opts = {}) => {},
153
- keyboard: {
154
- press: async (key) => {},
155
- type: async (text) => {},
156
- down: async (key) => {},
157
- up: async (key) => {},
158
- },
159
188
  pdf: async (opts = {}) => Buffer.from('%PDF-1.4 mock playwright'),
160
189
  };
161
190
  }
@@ -289,39 +318,7 @@ export function createMockPuppeteerPage(options = {}) {
289
318
  }
290
319
  },
291
320
  content: async () => '<html><body>Mock page</body></html>',
292
- mainFrame: () => ({
293
- url: () => url,
294
- }),
295
- bringToFront: async () => {},
296
- on: (event, handler) => {
297
- if (!eventListeners.has(event)) {
298
- eventListeners.set(event, []);
299
- }
300
- eventListeners.get(event).push(handler);
301
- },
302
- off: (event, handler) => {
303
- const handlers = eventListeners.get(event);
304
- if (handlers) {
305
- const idx = handlers.indexOf(handler);
306
- if (idx !== -1) {
307
- handlers.splice(idx, 1);
308
- }
309
- }
310
- },
311
- emit: (event, data) => {
312
- const handlers = eventListeners.get(event);
313
- if (handlers) {
314
- handlers.forEach((h) => h(data));
315
- }
316
- },
317
- click: async (sel, opts = {}) => {},
318
- type: async (sel, text, opts = {}) => {},
319
- keyboard: {
320
- press: async (key) => {},
321
- type: async (text) => {},
322
- down: async (key) => {},
323
- up: async (key) => {},
324
- },
321
+ ...createSharedPageBehaviors({ url, eventListeners }),
325
322
  pdf: async (opts = {}) => Buffer.from('%PDF-1.4 mock puppeteer'),
326
323
  };
327
324
 
@@ -0,0 +1,96 @@
1
+ /**
2
+ * A tiny static server for the real-browser readiness and click tests.
3
+ *
4
+ * The issue #89 repros need pages that a mock cannot fake: an element far
5
+ * below the fold, a button whose handler does nothing, a link that navigates,
6
+ * and a page whose network never goes quiet. Serving them from localhost keeps
7
+ * the tests self-contained - no external app has to be running.
8
+ */
9
+
10
+ import { sendHtml, sendNotFound, startFixtureHost } from './fixture-server.js';
11
+
12
+ /** A button 4200px down a tall page, matching the issue #89 repro. */
13
+ export const FAR_BELOW_FOLD_PAGE = `<!doctype html>
14
+ <html><body style="margin:0">
15
+ <div style="height:4200px"></div>
16
+ <button id="target" style="height:40px">Far below the fold</button>
17
+ <div style="height:2000px"></div>
18
+ <script>
19
+ window.__clicks = 0;
20
+ document.getElementById('target').addEventListener('click', () => {
21
+ window.__clicks += 1;
22
+ document.getElementById('target').setAttribute('aria-pressed', 'true');
23
+ });
24
+ </script>
25
+ </body></html>`;
26
+
27
+ /** A button in the viewport whose handler does nothing observable. */
28
+ export const NO_OP_PAGE = `<!doctype html>
29
+ <html><body style="margin:0">
30
+ <button id="noop" style="height:40px">Does nothing</button>
31
+ <script>
32
+ window.__clicks = 0;
33
+ document.getElementById('noop').addEventListener('click', () => {
34
+ window.__clicks += 1;
35
+ });
36
+ </script>
37
+ </body></html>`;
38
+
39
+ /** A link that navigates to another page on the same server. */
40
+ export const NAVIGATES_PAGE = `<!doctype html>
41
+ <html><body style="margin:0">
42
+ <a id="go" href="/arrived">Go</a>
43
+ </body></html>`;
44
+
45
+ /** The navigation target. */
46
+ export const ARRIVED_PAGE =
47
+ '<!doctype html><html><body><h1 id="arrived">Arrived</h1></body></html>';
48
+
49
+ /** A page that keeps a request in flight forever, so the network never idles. */
50
+ export const NEVER_IDLE_PAGE = `<!doctype html>
51
+ <html><body>
52
+ <p id="loaded">loaded</p>
53
+ <script>fetch('/hang').catch(() => {});</script>
54
+ </body></html>`;
55
+
56
+ const PAGES = {
57
+ '/far-below-fold': FAR_BELOW_FOLD_PAGE,
58
+ '/no-op': NO_OP_PAGE,
59
+ '/navigates': NAVIGATES_PAGE,
60
+ '/arrived': ARRIVED_PAGE,
61
+ '/never-idle': NEVER_IDLE_PAGE,
62
+ };
63
+
64
+ /**
65
+ * Start the fixture server on an ephemeral port.
66
+ *
67
+ * @returns {Promise<{baseUrl: string, close: Function}>} Server handle
68
+ */
69
+ export async function startFixtureServer() {
70
+ /** Requests parked on purpose, closed when the server shuts down. */
71
+ const hanging = new Set();
72
+
73
+ return startFixtureHost(
74
+ (path, req, res) => {
75
+ if (path === '/hang') {
76
+ hanging.add(res);
77
+ res.on('close', () => hanging.delete(res));
78
+ return;
79
+ }
80
+
81
+ const body = PAGES[path];
82
+ if (body === undefined) {
83
+ sendNotFound(res);
84
+ return;
85
+ }
86
+
87
+ sendHtml(res, body);
88
+ },
89
+ () => {
90
+ for (const res of hanging) {
91
+ res.destroy();
92
+ }
93
+ hanging.clear();
94
+ }
95
+ );
96
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Temporary directories for tests that write real files.
3
+ *
4
+ * Download and trace tests both need a directory of their own per test, and
5
+ * both may leave it read-only on purpose, so the lifecycle lives in one place.
6
+ */
7
+
8
+ import { afterEach, beforeEach } from 'node:test';
9
+ import fs from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ /**
14
+ * Create a temporary directory.
15
+ *
16
+ * @param {string} [prefix] - Name prefix, to tell suites apart under /tmp
17
+ * @returns {Promise<string>} Absolute directory path
18
+ */
19
+ export async function makeTempDirectory(prefix = 'bc-test-') {
20
+ return fs.mkdtemp(path.join(os.tmpdir(), prefix));
21
+ }
22
+
23
+ /**
24
+ * Give each test in a suite its own directory.
25
+ *
26
+ * @param {string} [prefix] - Name prefix for the directories
27
+ * @returns {{path: string}} Holder whose `path` is the current directory
28
+ */
29
+ export function useTempDirectory(prefix) {
30
+ const holder = { path: '' };
31
+
32
+ beforeEach(async () => {
33
+ holder.path = await makeTempDirectory(prefix);
34
+ });
35
+
36
+ afterEach(async () => {
37
+ // A test may have made the directory read-only on purpose; removing it
38
+ // still has to work, or the next test inherits the mess.
39
+ await fs.chmod(holder.path, 0o700).catch(() => {});
40
+ await fs.rm(holder.path, { recursive: true, force: true });
41
+ });
42
+
43
+ return holder;
44
+ }