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,62 @@
1
+ /**
2
+ * Shared fixtures for the click tests.
3
+ *
4
+ * The activation tests and the `clickElement` tests exercise the same two
5
+ * shapes - an element state probe result and a click-point probe result - so
6
+ * they live here instead of drifting apart in two files.
7
+ */
8
+
9
+ /** State of a plain, enabled button that nothing has changed. */
10
+ export const UNCHANGED_STATE = Object.freeze({
11
+ disabled: false,
12
+ ariaPressed: 'false',
13
+ ariaExpanded: null,
14
+ ariaSelected: null,
15
+ checked: false,
16
+ className: 'btn',
17
+ isConnected: true,
18
+ });
19
+
20
+ /** Click-point probe result for an element fully inside the viewport. */
21
+ export const IN_VIEWPORT_POINT = Object.freeze({
22
+ x: 50,
23
+ y: 60,
24
+ width: 100,
25
+ height: 40,
26
+ top: 40,
27
+ left: 0,
28
+ viewport: { width: 800, height: 600 },
29
+ scroll: { x: 0, y: 0 },
30
+ inViewport: true,
31
+ hitsTarget: true,
32
+ });
33
+
34
+ /**
35
+ * A mutable window scroll position with the `evaluateOnPage` contract the
36
+ * click code uses: an empty argument list reads the position, a one-element
37
+ * list writes it. The list is what the real adapter takes - passing a bare
38
+ * object made Playwright try to spread it.
39
+ *
40
+ * @param {number} [initial] - Initial vertical scroll offset
41
+ * @returns {{current: Function, set: Function, evaluateOnPage: Function}} Scroll model
42
+ */
43
+ export function createScrollModel(initial = 0) {
44
+ let scrollY = initial;
45
+
46
+ return {
47
+ current: () => scrollY,
48
+ set: (next) => {
49
+ scrollY = next;
50
+ },
51
+ evaluateOnPage: async (fn, args = []) => {
52
+ if (!Array.isArray(args)) {
53
+ throw new TypeError('evaluateOnPage takes an argument list');
54
+ }
55
+ if (args.length > 0) {
56
+ scrollY = args[0].y;
57
+ return undefined;
58
+ }
59
+ return { x: 0, y: scrollY };
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Fixtures for the managed download tests (issue #88).
3
+ *
4
+ * The fake context speaks exactly the part of Playwright's download API the
5
+ * manager uses, so the tests exercise the real event plumbing without needing
6
+ * a browser.
7
+ */
8
+
9
+ import { EventEmitter } from 'node:events';
10
+ import { setImmediate as yieldToEngine } from 'node:timers/promises';
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import {
14
+ makeTempDirectory as makeDirectory,
15
+ useTempDirectory,
16
+ } from './temp-directory.js';
17
+
18
+ /**
19
+ * Create a temporary directory that a test can write downloads into.
20
+ *
21
+ * @returns {Promise<string>} Absolute directory path
22
+ */
23
+ export async function makeTempDirectory() {
24
+ return makeDirectory('bc-downloads-');
25
+ }
26
+
27
+ /**
28
+ * Build a fake Playwright download whose bytes are already on disk.
29
+ *
30
+ * @param {Object} options - Download options
31
+ * @param {string} options.dir - Directory to stage the bytes in
32
+ * @param {string} options.suggestedFilename - Name the page suggested
33
+ * @param {string|Buffer} [options.body] - File contents
34
+ * @param {string} [options.url] - Source URL
35
+ * @param {string} [options.failure] - Engine failure text, when it failed
36
+ * @returns {Promise<Object>} Fake download object
37
+ */
38
+ export async function makeFakeDownload({
39
+ dir,
40
+ suggestedFilename,
41
+ body = 'hello',
42
+ url = 'https://example.com/file',
43
+ failure = null,
44
+ }) {
45
+ const staged = path.join(
46
+ dir,
47
+ `staged-${Math.random().toString(36).slice(2)}`
48
+ );
49
+ if (!failure) {
50
+ await fs.writeFile(staged, body);
51
+ }
52
+
53
+ return {
54
+ url: () => url,
55
+ suggestedFilename: () => suggestedFilename,
56
+ failure: async () => failure,
57
+ path: async () => staged,
58
+ stagedPath: staged,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Expose an emitter the way an engine object does.
64
+ *
65
+ * @param {Object} emitter - Backing event emitter
66
+ * @returns {{on: Function, off: Function}} Subscription methods
67
+ */
68
+ function subscriptionsOf(emitter) {
69
+ return {
70
+ on: (event, listener) => emitter.on(event, listener),
71
+ off: (event, listener) => emitter.off(event, listener),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Create a fake browser context that emits download events.
77
+ *
78
+ * @returns {Object} Context with `on`, `off` and `emitDownload`
79
+ */
80
+ export function createFakeContext() {
81
+ const emitter = new EventEmitter();
82
+ return {
83
+ ...subscriptionsOf(emitter),
84
+ /**
85
+ * Emit a download and wait for the manager to finish handling it.
86
+ *
87
+ * @param {Object} download - Fake download object
88
+ * @returns {Promise<void>}
89
+ */
90
+ emitDownload: async (download) => {
91
+ emitter.emit('download', download);
92
+ // The manager's handler is async; yielding twice lets it run to the
93
+ // point where the artifact is saved and published.
94
+ await yieldToEngine();
95
+ await yieldToEngine();
96
+ },
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Create a fake CDP session that speaks the Browser download domain.
102
+ *
103
+ * @returns {Object} Session with `send`, `on`, `off` and `emit`
104
+ */
105
+ export function createFakeCdpSession() {
106
+ const emitter = new EventEmitter();
107
+ const sent = [];
108
+ return {
109
+ ...subscriptionsOf(emitter),
110
+ sent,
111
+ send: async (method, params) => {
112
+ sent.push({ method, params });
113
+ },
114
+ emit: (event, payload) => emitter.emit(event, payload),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Give a suite its own download directory, created and removed per test.
120
+ *
121
+ * @returns {{path: string}} Holder whose `path` is the current directory
122
+ */
123
+ export function useTempDownloadDirectory() {
124
+ return useTempDirectory('bc-downloads-');
125
+ }
@@ -0,0 +1,129 @@
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
+ /**
19
+ * A PDF large enough that Chromium writes it in more than one chunk.
20
+ *
21
+ * Issue #92 is a race between `Browser.downloadProgress` reporting
22
+ * `completed` and the staged bytes being readable, so a body the browser can
23
+ * write in a single instant is the one body that would not exercise it.
24
+ */
25
+ export const BLOB_PDF_BODY = `%PDF-1.7\n${'blob pdf payload line\n'.repeat(40000)}%%EOF\n`;
26
+
27
+ /** A page offering the download shapes the issue lists. */
28
+ export const DOWNLOADS_PAGE = `<!doctype html>
29
+ <html><body style="margin:0">
30
+ <a id="link" href="/file/report.pdf" download>Download the report</a>
31
+ <a id="navigating" href="/attachment">Open the statement</a>
32
+ <a id="unnamed" href="/unnamed" download>Download without a name</a>
33
+ <a id="broken" href="/aborted" download>Download a broken file</a>
34
+ <button id="blob">Build and download</button>
35
+ <button id="blob-pdf">Build and download a PDF</button>
36
+ <script>
37
+ const downloadBlob = (parts, type, name) => {
38
+ const url = URL.createObjectURL(new Blob(parts, { type }));
39
+ const anchor = document.createElement('a');
40
+ anchor.href = url;
41
+ anchor.download = name;
42
+ document.body.appendChild(anchor);
43
+ anchor.click();
44
+ };
45
+ document.getElementById('blob').addEventListener('click', () => {
46
+ downloadBlob([${JSON.stringify(REPORT_BODY)}], 'text/plain', 'generated.txt');
47
+ });
48
+ document.getElementById('blob-pdf').addEventListener('click', () => {
49
+ // Built from a repeated unit rather than shipped as one literal: the
50
+ // page has to produce the bytes, not receive them over the wire.
51
+ const line = ${JSON.stringify('blob pdf payload line\n')};
52
+ downloadBlob(
53
+ ['%PDF-1.7\\n', line.repeat(40000), '%%EOF\\n'],
54
+ 'application/pdf',
55
+ 'generated.pdf'
56
+ );
57
+ });
58
+ </script>
59
+ </body></html>`;
60
+
61
+ /**
62
+ * Answer a request for one of the fixture downloads.
63
+ *
64
+ * @param {string} path - Request path
65
+ * @param {Object} res - Node response
66
+ * @returns {boolean} Whether the request was a download
67
+ */
68
+ function serveDownload(path, res) {
69
+ if (path.startsWith('/file/')) {
70
+ res.writeHead(200, {
71
+ 'content-type': 'application/pdf',
72
+ 'content-disposition': `attachment; filename="${path.slice('/file/'.length)}"`,
73
+ });
74
+ res.end(REPORT_BODY);
75
+ return true;
76
+ }
77
+
78
+ if (path === '/attachment') {
79
+ // No `download` attribute on the link: the browser starts navigating and
80
+ // turns the navigation into a download once it reads this header.
81
+ res.writeHead(200, {
82
+ 'content-type': 'application/pdf',
83
+ 'content-disposition': 'attachment; filename="statement.pdf"',
84
+ });
85
+ res.end(REPORT_BODY);
86
+ return true;
87
+ }
88
+
89
+ if (path === '/unnamed') {
90
+ // Nothing names this file and the server declares nothing useful: only the
91
+ // bytes themselves can give it a suffix.
92
+ res.writeHead(200, { 'content-type': 'application/octet-stream' });
93
+ res.end(PDF_BODY);
94
+ return true;
95
+ }
96
+
97
+ if (path === '/aborted') {
98
+ res.writeHead(200, {
99
+ 'content-type': 'application/pdf',
100
+ 'content-length': '1000000',
101
+ 'content-disposition': 'attachment; filename="broken.pdf"',
102
+ });
103
+ res.write('partial');
104
+ res.destroy();
105
+ return true;
106
+ }
107
+
108
+ return false;
109
+ }
110
+
111
+ /**
112
+ * Start the download fixture server on an ephemeral port.
113
+ *
114
+ * @returns {Promise<{baseUrl: string, close: Function}>} Server handle
115
+ */
116
+ export async function startDownloadServer() {
117
+ return startFixtureHost((path, req, res) => {
118
+ if (serveDownload(path, res)) {
119
+ return;
120
+ }
121
+
122
+ if (path === '/') {
123
+ sendHtml(res, DOWNLOADS_PAGE);
124
+ return;
125
+ }
126
+
127
+ sendNotFound(res);
128
+ });
129
+ }
@@ -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