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,534 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ import {
8
+ WebDriverPage,
9
+ createWebDriverPage,
10
+ isWebDriver,
11
+ } from '../../../src/core/webdriver-page.js';
12
+ import {
13
+ SeleniumAdapter,
14
+ createEngineAdapter,
15
+ } from '../../../src/core/engine-adapter.js';
16
+ import { detectEngine } from '../../../src/core/engine-detection.js';
17
+ import { isNavigationError } from '../../../src/core/navigation-safety.js';
18
+ import { WEBDRIVER_KEYS } from '../../../src/core/webdriver-conversions.js';
19
+ import { makeBrowserCommander } from '../../../src/factory.js';
20
+ import {
21
+ createMockBidi,
22
+ createMockDriver,
23
+ createMockWebElement,
24
+ } from '../../helpers/webdriver-mocks.js';
25
+
26
+ const BIDI_CAPABILITIES = { webSocketUrl: 'ws://127.0.0.1:1/session/x' };
27
+
28
+ function bidiPage({ responses, unsupported, driverOptions } = {}) {
29
+ const bidi = createMockBidi({ responses, unsupported });
30
+ const driver = createMockDriver({
31
+ capabilities: BIDI_CAPABILITIES,
32
+ ...driverOptions,
33
+ });
34
+ const page = new WebDriverPage(driver, { connectBidi: async () => bidi });
35
+ return { page, driver, bidi };
36
+ }
37
+
38
+ const lastActions = (driver) =>
39
+ driver.calls.filter(([name]) => name === 'actions').at(-1)[1];
40
+
41
+ describe('WebDriverPage', () => {
42
+ it('only wraps something shaped like a WebDriver', () => {
43
+ assert.ok(isWebDriver(createMockDriver()));
44
+ assert.ok(!isWebDriver({ $: () => {} }));
45
+ assert.throws(() => new WebDriverPage({}), /needs a selenium-webdriver/);
46
+ });
47
+
48
+ it('knows its URL and context from the start', async () => {
49
+ const driver = createMockDriver({ url: 'https://example.com/' });
50
+ const page = await createWebDriverPage(driver);
51
+ assert.equal(page.url(), 'https://example.com/');
52
+ assert.equal(page.contextIdSync(), 'context-1');
53
+ assert.equal(page.engine, 'selenium');
54
+ });
55
+
56
+ it('navigates with Get and applies a page-load timeout', async () => {
57
+ const driver = createMockDriver();
58
+ const page = new WebDriverPage(driver);
59
+ assert.equal(
60
+ await page.goto('https://example.com/a', { timeout: 5000 }),
61
+ null
62
+ );
63
+ assert.deepEqual(driver.calls.slice(0, 2), [
64
+ ['setTimeouts', { pageLoad: 5000 }],
65
+ ['get', 'https://example.com/a'],
66
+ ]);
67
+ assert.equal(page.url(), 'https://example.com/a');
68
+ await page.goBack();
69
+ await page.goForward();
70
+ await page.reload();
71
+ assert.deepEqual(
72
+ driver.calls.slice(2).map(([name]) => name),
73
+ ['back', 'forward', 'refresh']
74
+ );
75
+ });
76
+
77
+ it('evaluates functions with arguments and learns the URL', async () => {
78
+ const driver = createMockDriver({
79
+ url: 'https://example.com/b',
80
+ script: (source, args) => args[0] * 2,
81
+ });
82
+ const page = new WebDriverPage(driver);
83
+ assert.equal(await page.evaluate((x) => x * 2, 21), 42);
84
+ const [, source, args] = driver.calls.at(-1);
85
+ assert.match(source, /\(x\) => x \* 2/);
86
+ assert.deepEqual(args, [21]);
87
+ assert.equal(page.url(), 'https://example.com/b');
88
+ });
89
+
90
+ it('finds elements by CSS and evaluates on them', async () => {
91
+ const element = createMockWebElement();
92
+ const driver = createMockDriver({
93
+ elements: { '#a': [element] },
94
+ script: (source, args) => (args[0] === element ? 'text' : null),
95
+ });
96
+ const page = new WebDriverPage(driver);
97
+ assert.equal(await page.$('#a'), element);
98
+ assert.equal(await page.$('#missing'), null);
99
+ assert.deepEqual(await page.$$('#a'), [element]);
100
+ assert.equal(await page.$eval('#a', (el) => el.textContent), 'text');
101
+ await assert.rejects(
102
+ () => page.$eval('#missing', (el) => el),
103
+ /failed to find element matching selector "#missing"/
104
+ );
105
+ });
106
+
107
+ it('waits for a selector to become visible, or times out like Puppeteer', async () => {
108
+ const element = createMockWebElement({ displayed: false });
109
+ const driver = createMockDriver({ elements: { '#a': [element] } });
110
+ const page = new WebDriverPage(driver);
111
+ setTimeout(() => {
112
+ element.displayed = true;
113
+ }, 150);
114
+ assert.equal(
115
+ await page.waitForSelector('#a', { visible: true, timeout: 2000 }),
116
+ element
117
+ );
118
+ assert.equal(await page.waitForSelector('#gone', { hidden: true }), null);
119
+ await assert.rejects(
120
+ () => page.waitForSelector('#never', { timeout: 150 }),
121
+ (error) =>
122
+ error.name === 'TimeoutError' && /150ms exceeded/.test(error.message)
123
+ );
124
+ });
125
+
126
+ it('clicks with Element Click, and uses actions for other buttons and counts', async () => {
127
+ const elements = {};
128
+ const driver = createMockDriver({ elements });
129
+ const element = createMockWebElement({}, driver.calls);
130
+ elements['#b'] = [element];
131
+ const page = new WebDriverPage(driver);
132
+ await page.click('#b');
133
+ assert.ok(driver.calls.some(([name]) => name === 'element.click'));
134
+ await page.click('#b', { button: 'right', clickCount: 2 });
135
+ assert.deepEqual(lastActions(driver), [
136
+ ['move', { origin: element }],
137
+ ['press', 2],
138
+ ['release', 2],
139
+ ['press', 2],
140
+ ['release', 2],
141
+ ]);
142
+ await assert.rejects(() => page.click('#none'), /No element found/);
143
+ });
144
+
145
+ it('presses chords and types through input actions', async () => {
146
+ const driver = createMockDriver();
147
+ const page = new WebDriverPage(driver);
148
+ await page.keyboard.press('Control+A');
149
+ assert.deepEqual(lastActions(driver), [
150
+ ['keyDown', WEBDRIVER_KEYS.Control],
151
+ ['keyDown', 'A'],
152
+ ['keyUp', 'A'],
153
+ ['keyUp', WEBDRIVER_KEYS.Control],
154
+ ]);
155
+ await page.keyboard.type('hi');
156
+ assert.deepEqual(lastActions(driver), [['sendKeys', 'hi']]);
157
+ await page.keyboard.down('Shift');
158
+ assert.deepEqual(lastActions(driver), [['keyDown', WEBDRIVER_KEYS.Shift]]);
159
+ });
160
+
161
+ it('moves, clicks and scrolls the mouse in viewport coordinates', async () => {
162
+ const driver = createMockDriver();
163
+ const page = new WebDriverPage(driver);
164
+ await page.mouse.click(10.4, 20.6);
165
+ assert.deepEqual(lastActions(driver), [
166
+ ['move', { x: 10, y: 21, origin: 'viewport' }],
167
+ ['press', 0],
168
+ ['release', 0],
169
+ ]);
170
+ await page.mouse.wheel({ deltaY: 300 });
171
+ assert.deepEqual(lastActions(driver), [
172
+ ['scroll', 10, 21, 0, 300, 'viewport'],
173
+ ]);
174
+ });
175
+
176
+ it('prints to PDF with Print Page and writes the file', async () => {
177
+ const directory = await mkdtemp(path.join(tmpdir(), 'wd-pdf-'));
178
+ try {
179
+ const driver = createMockDriver();
180
+ const page = new WebDriverPage(driver);
181
+ const file = path.join(directory, 'out.pdf');
182
+ const buffer = await page.pdf({ path: file, landscape: true });
183
+ assert.equal(buffer.toString(), '%PDF-mock');
184
+ assert.equal((await readFile(file)).toString(), '%PDF-mock');
185
+ assert.deepEqual(driver.calls.at(-1), [
186
+ 'printPage',
187
+ { orientation: 'landscape' },
188
+ ]);
189
+ await assert.rejects(
190
+ () => page.pdf({ headerTemplate: '<b>x</b>' }),
191
+ /webdriver-print-options/
192
+ );
193
+ } finally {
194
+ await rm(directory, { recursive: true, force: true });
195
+ }
196
+ });
197
+
198
+ it('takes viewport screenshots classically and full-page ones over BiDi', async () => {
199
+ const { page, bidi } = bidiPage({
200
+ responses: {
201
+ 'browsingContext.captureScreenshot': {
202
+ data: Buffer.from('full').toString('base64'),
203
+ },
204
+ },
205
+ });
206
+ assert.equal((await page.screenshot()).toString(), 'png');
207
+ assert.equal(
208
+ await page.screenshot({ encoding: 'base64' }),
209
+ Buffer.from('png').toString('base64')
210
+ );
211
+ const full = await page.screenshot({
212
+ fullPage: true,
213
+ type: 'jpeg',
214
+ quality: 80,
215
+ });
216
+ assert.equal(full.toString(), 'full');
217
+ assert.deepEqual(bidi.sent.at(-1).params, {
218
+ context: 'context-1',
219
+ origin: 'document',
220
+ format: { type: 'image/jpeg', quality: 0.8 },
221
+ });
222
+ await page.screenshot({ clip: { x: 1, y: 2, width: 3, height: 4 } });
223
+ assert.deepEqual(bidi.sent.at(-1).params.clip, {
224
+ type: 'box',
225
+ x: 1,
226
+ y: 2,
227
+ width: 3,
228
+ height: 4,
229
+ });
230
+ });
231
+
232
+ it('explains that full-page screenshots need BiDi on a classic session', async () => {
233
+ const page = new WebDriverPage(createMockDriver());
234
+ assert.equal(await page.bidi(), null);
235
+ await assert.rejects(
236
+ () => page.screenshot({ fullPage: true }),
237
+ /needs WebDriver BiDi; launch with bidi: true.*webdriver-classic-has-no-events/
238
+ );
239
+ });
240
+
241
+ it('reads, adds and deletes cookies in the Puppeteer shape', async () => {
242
+ const driver = createMockDriver({
243
+ cookies: [{ name: 'a', value: '1', domain: 'example.com' }],
244
+ });
245
+ const page = new WebDriverPage(driver);
246
+ assert.equal((await page.cookies())[0].expires, -1);
247
+ await page.setCookie({ name: 'b', value: '2', expires: 1900000000 });
248
+ assert.deepEqual(driver.calls.at(-1), [
249
+ 'addCookie',
250
+ { name: 'b', value: '2', expiry: 1900000000 },
251
+ ]);
252
+ await page.deleteCookie({ name: 'a' });
253
+ assert.deepEqual(
254
+ (await page.cookies()).map((cookie) => cookie.name),
255
+ ['b']
256
+ );
257
+ });
258
+
259
+ it('adds and removes preload scripts over BiDi', async () => {
260
+ const { page, bidi } = bidiPage({
261
+ responses: { 'script.addPreloadScript': { script: 'preload-1' } },
262
+ });
263
+ const { identifier } = await page.evaluateOnNewDocument((value) => {
264
+ window.flag = value;
265
+ }, 'on');
266
+ assert.equal(identifier, 'preload-1');
267
+ const { params } = bidi.sent.at(-1);
268
+ assert.deepEqual(params.contexts, ['context-1']);
269
+ assert.match(params.functionDeclaration, /\(\.\.\.\["on"\]\)/);
270
+ await page.removeScriptToEvaluateOnNewDocument(identifier);
271
+ assert.deepEqual(bidi.sent.at(-1), {
272
+ method: 'script.removePreloadScript',
273
+ params: { script: 'preload-1' },
274
+ });
275
+ });
276
+
277
+ it('turns BiDi error responses into errors', async () => {
278
+ const { page } = bidiPage({
279
+ responses: {
280
+ 'browsingContext.navigate': {
281
+ error: 'unknown error',
282
+ message: 'net::ERR_NAME_NOT_RESOLVED',
283
+ },
284
+ },
285
+ });
286
+ await assert.rejects(
287
+ () => page.navigateBidi('https://nowhere.invalid/'),
288
+ /browsingContext.navigate: unknown error: net::ERR_NAME_NOT_RESOLVED/
289
+ );
290
+ });
291
+
292
+ it('closes its window and reports itself closed', async () => {
293
+ const driver = createMockDriver();
294
+ const page = new WebDriverPage(driver);
295
+ let closed = false;
296
+ page.on('close', () => {
297
+ closed = true;
298
+ });
299
+ await page.close();
300
+ assert.ok(page.isClosed());
301
+ assert.ok(closed);
302
+ assert.ok(page.mainFrame().isDetached());
303
+ });
304
+ });
305
+
306
+ describe('WebDriver BiDi event bridge', () => {
307
+ it('re-emits console entries of its own context as console messages', async () => {
308
+ const { page, bidi } = bidiPage();
309
+ const messages = [];
310
+ page.on('console', (message) => messages.push(message.text()));
311
+ await page.eventsReady();
312
+ bidi.fire('log.entryAdded', {
313
+ type: 'console',
314
+ method: 'log',
315
+ text: 'hello',
316
+ source: { context: 'context-1' },
317
+ });
318
+ bidi.fire('log.entryAdded', {
319
+ type: 'console',
320
+ text: 'other tab',
321
+ source: { context: 'context-2' },
322
+ });
323
+ assert.deepEqual(messages, ['hello']);
324
+ });
325
+
326
+ it('re-emits javascript log entries as page errors', async () => {
327
+ const { page, bidi } = bidiPage();
328
+ const errors = [];
329
+ page.on('pageerror', (error) => errors.push(error.message));
330
+ await page.eventsReady();
331
+ bidi.fire('log.entryAdded', {
332
+ type: 'javascript',
333
+ text: 'Error: boom',
334
+ source: { context: 'context-1' },
335
+ });
336
+ assert.deepEqual(errors, ['Error: boom']);
337
+ });
338
+
339
+ it('tracks navigations and falls back to domContentLoaded on older drivers', async () => {
340
+ const { page, bidi } = bidiPage({
341
+ unsupported: ['browsingContext.navigationCommitted'],
342
+ });
343
+ const urls = [];
344
+ page.once('framenavigated', (frame) => urls.push(frame.url()));
345
+ await page.eventsReady();
346
+ assert.ok(bidi.callbacks.has('browsingContext.domContentLoaded'));
347
+ assert.ok(bidi.callbacks.has('browsingContext.fragmentNavigated'));
348
+ bidi.fire('browsingContext.domContentLoaded', {
349
+ context: 'context-1',
350
+ url: 'https://example.com/next',
351
+ });
352
+ assert.deepEqual(urls, ['https://example.com/next']);
353
+ assert.equal(page.url(), 'https://example.com/next');
354
+ });
355
+
356
+ it('turns user prompts into dialogs that answer over BiDi', async () => {
357
+ const { page, bidi } = bidiPage();
358
+ page.on('dialog', (dialog) => dialog.accept('yes'));
359
+ await page.eventsReady();
360
+ bidi.fire('browsingContext.userPromptOpened', {
361
+ context: 'context-1',
362
+ type: 'prompt',
363
+ message: 'Sure?',
364
+ });
365
+ await new Promise((resolve) => setTimeout(resolve, 0));
366
+ assert.deepEqual(bidi.sent.at(-1), {
367
+ method: 'browsingContext.handleUserPrompt',
368
+ params: { context: 'context-1', accept: true, userText: 'yes' },
369
+ });
370
+ });
371
+
372
+ it('re-emits network events as requests and responses', async () => {
373
+ const { page, bidi } = bidiPage();
374
+ const seen = [];
375
+ page.on('request', (request) => seen.push(['request', request.url()]));
376
+ page.on('response', (response) =>
377
+ seen.push(['response', response.status()])
378
+ );
379
+ await page.eventsReady();
380
+ const request = {
381
+ request: 'r',
382
+ url: 'https://example.com/',
383
+ method: 'GET',
384
+ };
385
+ bidi.fire('network.beforeRequestSent', { request });
386
+ bidi.fire('network.responseCompleted', {
387
+ request,
388
+ response: { status: 200 },
389
+ });
390
+ assert.deepEqual(seen, [
391
+ ['request', 'https://example.com/'],
392
+ ['response', 200],
393
+ ]);
394
+ });
395
+
396
+ it('stays silent on a classic session and unsubscribes on dispose', async () => {
397
+ const classic = new WebDriverPage(createMockDriver());
398
+ classic.on('console', () => {});
399
+ await classic.eventsReady();
400
+
401
+ const { page, bidi } = bidiPage();
402
+ page.on('console', () => {});
403
+ page.on('pageerror', () => {});
404
+ await page.eventsReady();
405
+ assert.equal(bidi.callbacks.size, 1);
406
+ await page.dispose();
407
+ assert.deepEqual(bidi.unsubscribed, ['log.entryAdded']);
408
+ });
409
+ });
410
+
411
+ describe('SeleniumAdapter', () => {
412
+ it('is chosen for the selenium engine', () => {
413
+ const page = new WebDriverPage(createMockDriver());
414
+ const adapter = createEngineAdapter(page, 'selenium');
415
+ assert.ok(adapter instanceof SeleniumAdapter);
416
+ assert.equal(adapter.getEngineName(), 'selenium');
417
+ assert.equal(adapter.getDriver(), page.driver);
418
+ });
419
+
420
+ it('clicks, types and fills with native element commands', async () => {
421
+ const driver = createMockDriver();
422
+ const adapter = new SeleniumAdapter(new WebDriverPage(driver));
423
+ const element = createMockWebElement({}, driver.calls);
424
+ await adapter.click(element);
425
+ await adapter.type(element, 'abc');
426
+ await adapter.fill(element, 'xyz');
427
+ assert.deepEqual(
428
+ driver.calls.filter(([name]) => name.startsWith('element.')),
429
+ [
430
+ ['element.click', 'el'],
431
+ ['element.sendKeys', 'el', 'abc'],
432
+ ['element.clear', 'el'],
433
+ ['element.sendKeys', 'el', 'xyz'],
434
+ ]
435
+ );
436
+ });
437
+
438
+ it('force-clicks with a pointer action at the element', async () => {
439
+ const driver = createMockDriver();
440
+ const adapter = new SeleniumAdapter(new WebDriverPage(driver));
441
+ const element = createMockWebElement({}, driver.calls);
442
+ await adapter.click(element, { force: true });
443
+ assert.deepEqual(lastActions(driver), [
444
+ ['move', { origin: element }],
445
+ ['click'],
446
+ ]);
447
+ });
448
+
449
+ it('delivers console messages and navigations through BiDi helpers', async () => {
450
+ const { page, bidi } = bidiPage({
451
+ responses: {
452
+ 'browsingContext.navigate': {
453
+ navigation: 'n1',
454
+ url: 'https://a.test/',
455
+ },
456
+ },
457
+ });
458
+ const adapter = new SeleniumAdapter(page);
459
+ const texts = [];
460
+ const urls = [];
461
+ const stopConsole = await adapter.onConsoleMessage((message) =>
462
+ texts.push(message.text())
463
+ );
464
+ await adapter.onNavigation((url) => urls.push(url));
465
+ bidi.fire('log.entryAdded', { type: 'console', text: 'one' });
466
+ stopConsole();
467
+ bidi.fire('log.entryAdded', { type: 'console', text: 'two' });
468
+ bidi.fire('browsingContext.navigationCommitted', {
469
+ context: 'context-1',
470
+ url: 'https://a.test/',
471
+ });
472
+ assert.deepEqual(texts, ['one']);
473
+ assert.deepEqual(urls, ['https://a.test/']);
474
+
475
+ const result = await adapter.navigate('https://a.test/', { wait: 'none' });
476
+ assert.equal(result.navigation, 'n1');
477
+ assert.deepEqual(bidi.sent.at(-1).params, {
478
+ context: 'context-1',
479
+ url: 'https://a.test/',
480
+ wait: 'none',
481
+ });
482
+ });
483
+
484
+ it('refuses BiDi helpers on a classic session with a clear error', async () => {
485
+ const adapter = new SeleniumAdapter(new WebDriverPage(createMockDriver()));
486
+ await assert.rejects(
487
+ () => adapter.onConsoleMessage(() => {}),
488
+ /needs WebDriver BiDi/
489
+ );
490
+ });
491
+ });
492
+
493
+ describe('selenium engine detection and the commander', () => {
494
+ it('detects a raw WebDriver and the facade as selenium', () => {
495
+ const driver = createMockDriver();
496
+ assert.equal(detectEngine(driver), 'selenium');
497
+ assert.equal(detectEngine(new WebDriverPage(driver)), 'selenium');
498
+ });
499
+
500
+ it('wraps a raw WebDriver in the facade', () => {
501
+ const driver = createMockDriver();
502
+ const commander = makeBrowserCommander({
503
+ page: driver,
504
+ enableNetworkTracking: false,
505
+ enableNavigationManager: false,
506
+ });
507
+ assert.equal(commander.engine, 'selenium');
508
+ assert.ok(commander.page instanceof WebDriverPage);
509
+ assert.equal(commander.page.driver, driver);
510
+ });
511
+
512
+ it('keeps a facade it was given', () => {
513
+ const page = new WebDriverPage(createMockDriver());
514
+ const commander = makeBrowserCommander({
515
+ page,
516
+ enableNetworkTracking: false,
517
+ enableNavigationManager: false,
518
+ });
519
+ assert.equal(commander.page, page);
520
+ });
521
+
522
+ it('treats stale elements and closed windows as navigation errors', () => {
523
+ assert.ok(
524
+ isNavigationError(
525
+ new Error('stale element reference: stale element not found')
526
+ )
527
+ );
528
+ assert.ok(
529
+ isNavigationError(
530
+ new Error('no such window: target window already closed')
531
+ )
532
+ );
533
+ });
534
+ });
@@ -22,7 +22,7 @@ describe('AutomationControlled triggers', () => {
22
22
  }
23
23
  });
24
24
 
25
- it('finds each switch Chromium maps onto the feature', () => {
25
+ it('finds each switch that turns the feature on', () => {
26
26
  const found = detectAutomationControlledTriggers([
27
27
  '--enable-automation',
28
28
  '--headless=new',
@@ -32,16 +32,29 @@ describe('AutomationControlled triggers', () => {
32
32
 
33
33
  assert.deepEqual(found, [
34
34
  '--enable-automation',
35
- '--headless',
36
35
  '--remote-debugging-pipe',
37
36
  '--remote-debugging-port=0',
38
37
  ]);
39
38
  });
40
39
 
40
+ it('does not treat headless as a trigger', () => {
41
+ // Measured: a hand-started headless Chrome reports navigator.webdriver
42
+ // false, because the renderer never sees the --headless switch.
43
+ assert.deepEqual(
44
+ detectAutomationControlledTriggers([
45
+ '--headless',
46
+ '--headless=new',
47
+ '--headless=old',
48
+ ]),
49
+ []
50
+ );
51
+ });
52
+
41
53
  it('reports the argument as written next to the switch it matched', () => {
42
54
  assert.deepEqual(
43
- detectAutomationControlledTriggers(['--headless=new'])[0].argument,
44
- '--headless=new'
55
+ detectAutomationControlledTriggers(['--remote-debugging-port=0'])[0]
56
+ .argument,
57
+ '--remote-debugging-port=0'
45
58
  );
46
59
  });
47
60
 
@@ -44,7 +44,7 @@ describe('the documented limitations', () => {
44
44
 
45
45
  it('is the shared JSON asset Python and Rust also publish', () => {
46
46
  // The catalogue is data, not code, so the three packages ship one file
47
- // rather than three translations of the same eleven paragraphs.
47
+ // rather than three translations of the same sixteen paragraphs.
48
48
  const asset = JSON.parse(
49
49
  readFileSync(
50
50
  new URL('../../../src/fingerprint/limitations.json', import.meta.url),