browser-commander 0.20.0 → 0.21.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 (139) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/README.md +216 -32
  3. package/bin/browser-commander.js +16 -0
  4. package/eslint.config.js +10 -0
  5. package/examples/react-test-app/src/App.jsx +24 -23
  6. package/extension/background.js +14 -0
  7. package/extension/manifest.json +12 -0
  8. package/extension/relay-handler.js +353 -0
  9. package/package.json +19 -4
  10. package/src/browser/attach/differences.js +131 -0
  11. package/src/browser/attach/extension-relay.js +402 -0
  12. package/src/browser/attach/index.js +81 -0
  13. package/src/browser/attach/snapshot-launch.js +146 -0
  14. package/src/browser/attach/snapshot.js +374 -0
  15. package/src/browser/attach/websocket.js +615 -0
  16. package/src/browser/browser-cookie-cache.js +12 -17
  17. package/src/browser/browser-cookie-credentials.js +6 -9
  18. package/src/browser/browser-cookies.js +5 -2
  19. package/src/browser/cdp-session.js +94 -0
  20. package/src/browser/connector.js +42 -4
  21. package/src/browser/debugging-port.js +155 -0
  22. package/src/browser/launch-options.js +63 -52
  23. package/src/browser/launch-restrictions.json +147 -0
  24. package/src/browser/launcher.js +329 -133
  25. package/src/browser/media.js +5 -0
  26. package/src/browser/migration/bookmarks.js +73 -0
  27. package/src/browser/migration/chromium-crypto.js +89 -0
  28. package/src/browser/migration/cookies.js +155 -0
  29. package/src/browser/migration/extensions.js +187 -0
  30. package/src/browser/migration/firefox-bookmarks.js +144 -0
  31. package/src/browser/migration/firefox-der.js +84 -0
  32. package/src/browser/migration/firefox-nss.js +281 -0
  33. package/src/browser/migration/firefox.js +334 -0
  34. package/src/browser/migration/fs-utils.js +51 -0
  35. package/src/browser/migration/history.js +103 -0
  36. package/src/browser/migration/index.js +307 -0
  37. package/src/browser/migration/os-crypt-keys.js +106 -0
  38. package/src/browser/migration/passwords.js +171 -0
  39. package/src/browser/migration/preferences.js +132 -0
  40. package/src/browser/migration/sqlite-snapshot.js +138 -0
  41. package/src/browser/open-in-user-browser.js +112 -0
  42. package/src/browser/parity.js +439 -0
  43. package/src/browser/profile-directory.js +102 -0
  44. package/src/browser/real-browser.js +513 -306
  45. package/src/browser/restrictions.js +148 -0
  46. package/src/browser/system-browser.js +232 -0
  47. package/src/browser/webdriver.js +572 -0
  48. package/src/cli/args.js +253 -0
  49. package/src/cli/commands.js +332 -0
  50. package/src/cli/dispatcher.js +99 -0
  51. package/src/cli/handle-methods.js +136 -0
  52. package/src/cli/handles.js +273 -0
  53. package/src/cli/high-level-methods.js +310 -0
  54. package/src/cli/main.js +67 -0
  55. package/src/cli/modules.js +100 -0
  56. package/src/cli/rpc-error.js +77 -0
  57. package/src/cli/script.js +77 -0
  58. package/src/cli/serve.js +144 -0
  59. package/src/cli/sessions.js +195 -0
  60. package/src/core/constants.js +7 -1
  61. package/src/core/engine-adapter.js +107 -1
  62. package/src/core/engine-detection.js +18 -3
  63. package/src/core/navigation-safety.js +4 -0
  64. package/src/core/webdriver-conversions.js +347 -0
  65. package/src/core/webdriver-events.js +229 -0
  66. package/src/core/webdriver-page.js +716 -0
  67. package/src/downloads/attach.js +6 -0
  68. package/src/elements/selectors.js +3 -2
  69. package/src/elements/visibility.js +4 -0
  70. package/src/exports.js +61 -2
  71. package/src/factory.js +19 -4
  72. package/src/fingerprint/apply.js +6 -0
  73. package/src/fingerprint/automation-parity.js +7 -8
  74. package/src/fingerprint/limitations.js +1 -1
  75. package/src/fingerprint/limitations.json +45 -2
  76. package/src/parity/harness.js +303 -0
  77. package/src/parity/probe.js +838 -0
  78. package/src/parity/version-page.js +144 -0
  79. package/src/utilities/subprocess.js +199 -0
  80. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  81. package/tests/e2e/cli.e2e.test.js +160 -0
  82. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  83. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  84. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  85. package/tests/e2e/playwright.e2e.test.js +25 -17
  86. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  87. package/tests/e2e/webdriver.e2e.test.js +239 -0
  88. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  89. package/tests/helpers/cli-fakes.js +214 -0
  90. package/tests/helpers/declared-api.js +80 -0
  91. package/tests/helpers/e2e-browser.js +40 -14
  92. package/tests/helpers/fake-chrome.js +124 -0
  93. package/tests/helpers/migration-fixtures.js +242 -0
  94. package/tests/helpers/temp-directory.js +24 -0
  95. package/tests/helpers/webdriver-mocks.js +183 -0
  96. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  97. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  98. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  99. package/tests/unit/browser/attach/websocket.test.js +240 -0
  100. package/tests/unit/browser/browser-cookies.test.js +2 -32
  101. package/tests/unit/browser/cdp-session.test.js +162 -0
  102. package/tests/unit/browser/connector.test.js +23 -0
  103. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  104. package/tests/unit/browser/launch-options.test.js +41 -22
  105. package/tests/unit/browser/launcher.test.js +281 -102
  106. package/tests/unit/browser/media.test.js +13 -1
  107. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  108. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  109. package/tests/unit/browser/migration/cookies.test.js +94 -0
  110. package/tests/unit/browser/migration/extensions.test.js +104 -0
  111. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  112. package/tests/unit/browser/migration/firefox.test.js +139 -0
  113. package/tests/unit/browser/migration/history.test.js +48 -0
  114. package/tests/unit/browser/migration/index.test.js +117 -0
  115. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  116. package/tests/unit/browser/migration/passwords.test.js +144 -0
  117. package/tests/unit/browser/migration/preferences.test.js +77 -0
  118. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  119. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  120. package/tests/unit/browser/parity.test.js +281 -0
  121. package/tests/unit/browser/real-browser.test.js +458 -33
  122. package/tests/unit/browser/webdriver.test.js +473 -0
  123. package/tests/unit/cli/bin.test.js +69 -0
  124. package/tests/unit/cli/declared-api.test.js +49 -0
  125. package/tests/unit/cli/dispatcher.test.js +429 -0
  126. package/tests/unit/cli/handles.test.js +170 -0
  127. package/tests/unit/cli/main.test.js +409 -0
  128. package/tests/unit/cli/serve.test.js +167 -0
  129. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  130. package/tests/unit/core/webdriver-page.test.js +534 -0
  131. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  132. package/tests/unit/fingerprint/limitations.test.js +1 -1
  133. package/tests/unit/parity/harness.test.js +138 -0
  134. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  135. package/tests/unit/scripts/feature-parity.test.js +221 -0
  136. package/tests/unit/traces/reader.test.js +1 -0
  137. package/tests/unit/traces/recorder.test.js +1 -0
  138. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  139. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,240 @@
1
+ import assert from 'node:assert';
2
+ import { once } from 'node:events';
3
+ import http from 'node:http';
4
+ import net from 'node:net';
5
+ import { afterEach, describe, it } from 'node:test';
6
+
7
+ import {
8
+ acceptUpgrade,
9
+ CLOSE_CODES,
10
+ computeAcceptKey,
11
+ connectWebSocket,
12
+ encodeFrame,
13
+ FrameDecoder,
14
+ OPCODES,
15
+ WebSocketProtocolError,
16
+ } from '../../../../src/browser/attach/websocket.js';
17
+
18
+ const servers = [];
19
+
20
+ afterEach(async () => {
21
+ for (const server of servers.splice(0)) {
22
+ server.closeAllConnections?.();
23
+ await new Promise((resolve) => server.close(resolve));
24
+ }
25
+ });
26
+
27
+ /**
28
+ * An HTTP server that upgrades every request and hands the server-side
29
+ * connection to the test.
30
+ */
31
+ async function startServer() {
32
+ const connections = [];
33
+ const server = http.createServer((request, response) => {
34
+ response.writeHead(426).end();
35
+ });
36
+ server.on('upgrade', (request, socket, head) => {
37
+ const connection = acceptUpgrade(request, socket, head, {
38
+ maxPayload: 1024,
39
+ });
40
+ if (connection) {
41
+ connections.push(connection);
42
+ server.emit('websocket', connection);
43
+ }
44
+ });
45
+ server.listen(0, '127.0.0.1');
46
+ await once(server, 'listening');
47
+ servers.push(server);
48
+ return {
49
+ url: `ws://127.0.0.1:${server.address().port}/`,
50
+ server,
51
+ connections,
52
+ };
53
+ }
54
+
55
+ async function connectPair() {
56
+ const { url, server } = await startServer();
57
+ const accepted = once(server, 'websocket');
58
+ const client = await connectWebSocket(url);
59
+ const [serverSide] = await accepted;
60
+ return { client, serverSide, url };
61
+ }
62
+
63
+ describe('WebSocket framing', () => {
64
+ it('computes the accept key of RFC 6455 section 1.3', () => {
65
+ assert.equal(
66
+ computeAcceptKey('dGhlIHNhbXBsZSBub25jZQ=='),
67
+ 's3pPLMBiTxaQ9kYGzzhZRbK+xOo='
68
+ );
69
+ });
70
+
71
+ it('round-trips 7-bit, 16-bit and 64-bit lengths, masked, byte by byte', () => {
72
+ for (const size of [0, 125, 126, 65_535, 70_000]) {
73
+ const payload = Buffer.alloc(size, 'x');
74
+ const decoder = new FrameDecoder({ requireMask: true });
75
+ const frame = encodeFrame(OPCODES.text, payload, { mask: true });
76
+ const frames = [];
77
+ const step = size > 1000 ? 4096 : 1;
78
+ for (let offset = 0; offset < frame.length; offset += step) {
79
+ frames.push(...decoder.push(frame.subarray(offset, offset + step)));
80
+ }
81
+ assert.equal(frames.length, 1, `size ${size}`);
82
+ assert.equal(frames[0].opcode, OPCODES.text);
83
+ assert.equal(frames[0].fin, true);
84
+ assert.ok(frames[0].payload.equals(payload), `size ${size}`);
85
+ }
86
+ });
87
+
88
+ it('decodes several frames from one chunk', () => {
89
+ const decoder = new FrameDecoder();
90
+ const frames = decoder.push(
91
+ Buffer.concat([
92
+ encodeFrame(OPCODES.text, 'a', { fin: false }),
93
+ encodeFrame(OPCODES.continuation, 'b'),
94
+ encodeFrame(OPCODES.ping, 'p'),
95
+ ])
96
+ );
97
+ assert.deepEqual(
98
+ frames.map(({ fin, opcode, payload }) => [fin, opcode, String(payload)]),
99
+ [
100
+ [false, OPCODES.text, 'a'],
101
+ [true, OPCODES.continuation, 'b'],
102
+ [true, OPCODES.ping, 'p'],
103
+ ]
104
+ );
105
+ });
106
+
107
+ it('rejects frames that break the protocol', () => {
108
+ const cases = [
109
+ [{ requireMask: true }, encodeFrame(OPCODES.text, 'x'), 1002],
110
+ [
111
+ { forbidMask: true },
112
+ encodeFrame(OPCODES.text, 'x', { mask: true }),
113
+ 1002,
114
+ ],
115
+ [{}, Buffer.from([0xc1, 0x00]), 1002],
116
+ [{}, Buffer.from([0x83, 0x00]), 1002],
117
+ [{}, encodeFrame(OPCODES.ping, Buffer.alloc(126)), 1002],
118
+ [{}, encodeFrame(OPCODES.ping, 'x', { fin: false }), 1002],
119
+ [{ maxPayload: 10 }, encodeFrame(OPCODES.text, Buffer.alloc(11)), 1009],
120
+ [
121
+ { maxPayload: 10 },
122
+ encodeFrame(OPCODES.text, Buffer.alloc(70_000)),
123
+ 1009,
124
+ ],
125
+ ];
126
+ for (const [options, frame, code] of cases) {
127
+ assert.throws(
128
+ () => new FrameDecoder(options).push(frame),
129
+ (error) =>
130
+ error instanceof WebSocketProtocolError && error.code === code
131
+ );
132
+ }
133
+ });
134
+ });
135
+
136
+ describe('WebSocketConnection', () => {
137
+ it('exchanges text messages both ways and answers pings', async () => {
138
+ const { client, serverSide } = await connectPair();
139
+
140
+ const toServer = once(serverSide, 'message');
141
+ client.send('héllo');
142
+ assert.deepEqual(await toServer, ['héllo']);
143
+
144
+ const toClient = once(client, 'message');
145
+ serverSide.send(JSON.stringify({ id: 1 }));
146
+ assert.deepEqual(await toClient, ['{"id":1}']);
147
+
148
+ const pong = once(client, 'pong');
149
+ client.ping(Buffer.from('beat'));
150
+ assert.equal(String((await pong)[0]), 'beat');
151
+
152
+ const closed = Promise.all([
153
+ once(client, 'close'),
154
+ once(serverSide, 'close'),
155
+ ]);
156
+ client.close(CLOSE_CODES.normal, 'bye');
157
+ const [[clientClose], [serverClose]] = await closed;
158
+ assert.deepEqual(serverClose, { code: 1000, reason: 'bye' });
159
+ assert.equal(clientClose.code, 1000);
160
+ assert.equal(client.send('late'), false);
161
+ });
162
+
163
+ it('joins fragmented messages', async () => {
164
+ const { client, serverSide } = await connectPair();
165
+ const message = once(serverSide, 'message');
166
+ client.socket.write(
167
+ Buffer.concat([
168
+ encodeFrame(OPCODES.text, 'frag', { mask: true, fin: false }),
169
+ encodeFrame(OPCODES.ping, '', { mask: true }),
170
+ encodeFrame(OPCODES.continuation, 'mented', { mask: true }),
171
+ ])
172
+ );
173
+ assert.deepEqual(await message, ['fragmented']);
174
+ client.terminate();
175
+ });
176
+
177
+ for (const [name, frame, code] of [
178
+ ['an unmasked client frame', encodeFrame(OPCODES.text, 'x'), 1002],
179
+ [
180
+ 'invalid UTF-8',
181
+ encodeFrame(OPCODES.text, Buffer.from([0xc3, 0x28]), { mask: true }),
182
+ 1007,
183
+ ],
184
+ [
185
+ 'a binary message',
186
+ encodeFrame(OPCODES.binary, 'x', { mask: true }),
187
+ 1003,
188
+ ],
189
+ [
190
+ 'a message over maxPayload',
191
+ encodeFrame(OPCODES.text, Buffer.alloc(2048), { mask: true }),
192
+ 1009,
193
+ ],
194
+ [
195
+ 'a stray continuation frame',
196
+ encodeFrame(OPCODES.continuation, 'x', { mask: true }),
197
+ 1002,
198
+ ],
199
+ ]) {
200
+ it(`closes with ${code} on ${name}`, async () => {
201
+ const { client, serverSide } = await connectPair();
202
+ const closed = once(client, 'close');
203
+ const serverClosed = once(serverSide, 'close');
204
+ client.socket.write(frame);
205
+ const [{ code: received }] = await closed;
206
+ assert.equal(received, code);
207
+ await serverClosed;
208
+ });
209
+ }
210
+
211
+ it('refuses a bad handshake with 400 and an old version with 426', async () => {
212
+ const { url } = await startServer();
213
+ const { port } = new URL(url);
214
+ const statusOf = (headers) =>
215
+ new Promise((resolve, reject) => {
216
+ const socket = net.connect(port, '127.0.0.1', () => {
217
+ socket.write(`GET / HTTP/1.1\r\nHost: x\r\n${headers}\r\n`);
218
+ });
219
+ let text = '';
220
+ socket.on('data', (chunk) => {
221
+ text += chunk;
222
+ });
223
+ socket.on('end', () => resolve(Number(text.split(' ')[1])));
224
+ socket.on('error', reject);
225
+ });
226
+ assert.equal(
227
+ await statusOf(
228
+ 'Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: short\r\nSec-WebSocket-Version: 13\r\n'
229
+ ),
230
+ 400
231
+ );
232
+ assert.equal(
233
+ await statusOf(
234
+ 'Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 8\r\n'
235
+ ),
236
+ 426
237
+ );
238
+ assert.throws(() => connectWebSocket('http://127.0.0.1/'), TypeError);
239
+ });
240
+ });
@@ -37,6 +37,7 @@ import {
37
37
  listBrowserProfiles as publicListBrowserProfiles,
38
38
  readBrowserCookies as publicReadBrowserCookies,
39
39
  } from '../../../src/index.js';
40
+ import { writeFirefoxCookies } from '../../helpers/migration-fixtures.js';
40
41
 
41
42
  const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600;
42
43
  const execFile = promisify(execFileCallback);
@@ -136,43 +137,12 @@ async function createFirefoxProfile({ homeDir, rows }) {
136
137
  const root = path.join(homeDir, '.mozilla', 'firefox');
137
138
  const profileName = 'fixture.default-release';
138
139
  const profilePath = path.join(root, profileName);
139
- const cookiePath = path.join(profilePath, 'cookies.sqlite');
140
140
  await mkdir(profilePath, { recursive: true });
141
141
  await writeFile(
142
142
  path.join(root, 'profiles.ini'),
143
143
  `[Profile0]\nName=default-release\nIsRelative=1\nPath=${profileName}\nDefault=1\n`
144
144
  );
145
- const database = await openSqliteDatabase(cookiePath);
146
- database.exec(`
147
- CREATE TABLE moz_cookies (
148
- name TEXT,
149
- value TEXT,
150
- host TEXT,
151
- path TEXT,
152
- expiry INTEGER,
153
- isSecure INTEGER,
154
- isHttpOnly INTEGER,
155
- sameSite INTEGER
156
- );
157
- `);
158
- const insert = database.prepare(`
159
- INSERT INTO moz_cookies
160
- (name, value, host, path, expiry, isSecure, isHttpOnly, sameSite)
161
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
162
- `);
163
- for (const row of rows) {
164
- insert.run(
165
- row.name,
166
- row.value,
167
- row.host,
168
- row.path ?? '/',
169
- row.expiry ?? 0,
170
- row.secure ? 1 : 0,
171
- row.httpOnly ? 1 : 0,
172
- row.sameSite ?? 0
173
- );
174
- }
175
- database.close();
145
+ const cookiePath = await writeFirefoxCookies(profilePath, rows);
176
146
  return { cookiePath, profilePath, root };
177
147
  }
178
148
 
@@ -0,0 +1,162 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ import {
5
+ createCdpSession,
6
+ wrapCdpSession,
7
+ } from '../../../src/browser/cdp-session.js';
8
+ import { makeBrowserCommander } from '../../../src/factory.js';
9
+ import * as publicApi from '../../../src/index.js';
10
+
11
+ function createEngineSession() {
12
+ const calls = [];
13
+ const listeners = [];
14
+ return {
15
+ calls,
16
+ listeners,
17
+ detachCount: 0,
18
+ send(method, params) {
19
+ calls.push({ method, params });
20
+ return Promise.resolve({ echoed: method });
21
+ },
22
+ on(event, listener) {
23
+ listeners.push(['on', event, listener]);
24
+ },
25
+ once(event, listener) {
26
+ listeners.push(['once', event, listener]);
27
+ },
28
+ off(event, listener) {
29
+ listeners.push(['off', event, listener]);
30
+ },
31
+ detach() {
32
+ this.detachCount += 1;
33
+ return Promise.resolve();
34
+ },
35
+ };
36
+ }
37
+
38
+ function createPlaywrightPage(session) {
39
+ const context = {
40
+ targets: [],
41
+ newCDPSession(target) {
42
+ context.targets.push(target);
43
+ return Promise.resolve(session);
44
+ },
45
+ };
46
+ const page = { context: () => context, locator: () => ({}) };
47
+ return { page, context };
48
+ }
49
+
50
+ describe('createCdpSession', () => {
51
+ it('opens a Playwright session through page.context().newCDPSession(page)', async () => {
52
+ const session = createEngineSession();
53
+ const { page, context } = createPlaywrightPage(session);
54
+
55
+ const cdp = await createCdpSession(page, { engine: 'playwright' });
56
+
57
+ assert.equal(cdp.engine, 'playwright');
58
+ assert.equal(cdp.session, session);
59
+ assert.deepEqual(context.targets, [page]);
60
+ });
61
+
62
+ it('opens a Puppeteer session through page.createCDPSession()', async () => {
63
+ const session = createEngineSession();
64
+ const page = { createCDPSession: () => Promise.resolve(session) };
65
+
66
+ const cdp = await createCdpSession(page, { engine: 'puppeteer' });
67
+
68
+ assert.equal(cdp.engine, 'puppeteer');
69
+ assert.equal(cdp.session, session);
70
+ });
71
+
72
+ it('detects the engine from the page when it is not given', async () => {
73
+ const session = createEngineSession();
74
+ const { page } = createPlaywrightPage(session);
75
+
76
+ assert.equal((await createCdpSession(page)).engine, 'playwright');
77
+ });
78
+
79
+ it('accepts the older {browser, page, engine} form', async () => {
80
+ const session = createEngineSession();
81
+ const page = { createCDPSession: () => Promise.resolve(session) };
82
+
83
+ const cdp = await createCdpSession({
84
+ browser: {},
85
+ page,
86
+ engine: 'puppeteer',
87
+ });
88
+
89
+ assert.equal(cdp.session, session);
90
+ });
91
+
92
+ it('requires a page', async () => {
93
+ await assert.rejects(() => createCdpSession(undefined), /requires a page/u);
94
+ });
95
+ });
96
+
97
+ describe('the uniform CDP session surface', () => {
98
+ it('sends commands with empty params by default', async () => {
99
+ const session = createEngineSession();
100
+ const cdp = wrapCdpSession(session, 'puppeteer');
101
+
102
+ assert.deepEqual(await cdp.send('Browser.getVersion'), {
103
+ echoed: 'Browser.getVersion',
104
+ });
105
+ await cdp.send('Runtime.evaluate', { expression: '1' });
106
+ assert.deepEqual(session.calls, [
107
+ { method: 'Browser.getVersion', params: {} },
108
+ { method: 'Runtime.evaluate', params: { expression: '1' } },
109
+ ]);
110
+ });
111
+
112
+ it('forwards listeners and chains', () => {
113
+ const session = createEngineSession();
114
+ const cdp = wrapCdpSession(session, 'playwright');
115
+ const listener = () => {};
116
+
117
+ assert.equal(cdp.on('Network.requestWillBeSent', listener), cdp);
118
+ assert.equal(cdp.once('Page.loadEventFired', listener), cdp);
119
+ assert.equal(cdp.off('Network.requestWillBeSent', listener), cdp);
120
+ assert.deepEqual(
121
+ session.listeners.map(([kind, event]) => `${kind}:${event}`),
122
+ [
123
+ 'on:Network.requestWillBeSent',
124
+ 'once:Page.loadEventFired',
125
+ 'off:Network.requestWillBeSent',
126
+ ]
127
+ );
128
+ });
129
+
130
+ it('detaches once', async () => {
131
+ const session = createEngineSession();
132
+ const cdp = wrapCdpSession(session, 'playwright');
133
+
134
+ await cdp.detach();
135
+ await cdp.detach();
136
+
137
+ assert.equal(session.detachCount, 1);
138
+ });
139
+ });
140
+
141
+ describe('public cdpSession entry points', () => {
142
+ it('exports createCdpSession and wrapCdpSession', () => {
143
+ assert.equal(publicApi.createCdpSession, createCdpSession);
144
+ assert.equal(publicApi.wrapCdpSession, wrapCdpSession);
145
+ });
146
+
147
+ it('opens a session from a commander', async () => {
148
+ const session = createEngineSession();
149
+ const { page } = createPlaywrightPage(session);
150
+ const commander = makeBrowserCommander({
151
+ page,
152
+ enableNetworkTracking: false,
153
+ enableNavigationManager: false,
154
+ enableDialogManager: false,
155
+ });
156
+
157
+ const cdp = await commander.createCdpSession();
158
+
159
+ assert.equal(cdp.engine, 'playwright');
160
+ assert.equal(cdp.session, session);
161
+ });
162
+ });
@@ -3,6 +3,7 @@ import { describe, it } from 'node:test';
3
3
 
4
4
  import {
5
5
  connectBrowser,
6
+ pickForegroundPage,
6
7
  connectBrowserWithDependencies,
7
8
  } from '../../../src/browser/connector.js';
8
9
  import { connectBrowser as publicConnectBrowser } from '../../../src/index.js';
@@ -127,3 +128,25 @@ describe('connectBrowser', () => {
127
128
  );
128
129
  });
129
130
  });
131
+
132
+ describe('pickForegroundPage', () => {
133
+ const page = (id, state) => ({ id, evaluate: async () => state });
134
+
135
+ it('prefers the visible tab over the first one', async () => {
136
+ const picked = await pickForegroundPage([
137
+ page('ntp', 'hidden'),
138
+ page('whats-new', 'visible'),
139
+ ]);
140
+ assert.equal(picked.id, 'whats-new');
141
+ });
142
+
143
+ it('falls back to the first tab when none reports visible', async () => {
144
+ const failing = {
145
+ id: 'crashed',
146
+ evaluate: async () => Promise.reject(new Error('gone')),
147
+ };
148
+ const picked = await pickForegroundPage([failing, page('b', 'hidden')]);
149
+ assert.equal(picked.id, 'crashed');
150
+ assert.equal(await pickForegroundPage([]), undefined);
151
+ });
152
+ });