browser-commander 0.19.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 (155) hide show
  1. package/CHANGELOG.md +156 -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 +25 -6
  10. package/scripts/run-tests.mjs +4 -2
  11. package/src/browser/attach/differences.js +131 -0
  12. package/src/browser/attach/extension-relay.js +402 -0
  13. package/src/browser/attach/index.js +81 -0
  14. package/src/browser/attach/snapshot-launch.js +146 -0
  15. package/src/browser/attach/snapshot.js +374 -0
  16. package/src/browser/attach/websocket.js +615 -0
  17. package/src/browser/browser-cookie-cache.js +12 -17
  18. package/src/browser/browser-cookie-credentials.js +6 -9
  19. package/src/browser/browser-cookies.js +5 -2
  20. package/src/browser/cdp-session.js +94 -0
  21. package/src/browser/connector.js +42 -4
  22. package/src/browser/debugging-port.js +155 -0
  23. package/src/browser/launch-options.js +63 -52
  24. package/src/browser/launch-restrictions.json +147 -0
  25. package/src/browser/launcher.js +329 -133
  26. package/src/browser/media.js +5 -0
  27. package/src/browser/migration/bookmarks.js +73 -0
  28. package/src/browser/migration/chromium-crypto.js +89 -0
  29. package/src/browser/migration/cookies.js +155 -0
  30. package/src/browser/migration/extensions.js +187 -0
  31. package/src/browser/migration/firefox-bookmarks.js +144 -0
  32. package/src/browser/migration/firefox-der.js +84 -0
  33. package/src/browser/migration/firefox-nss.js +281 -0
  34. package/src/browser/migration/firefox.js +334 -0
  35. package/src/browser/migration/fs-utils.js +51 -0
  36. package/src/browser/migration/history.js +103 -0
  37. package/src/browser/migration/index.js +307 -0
  38. package/src/browser/migration/os-crypt-keys.js +106 -0
  39. package/src/browser/migration/passwords.js +171 -0
  40. package/src/browser/migration/preferences.js +132 -0
  41. package/src/browser/migration/sqlite-snapshot.js +138 -0
  42. package/src/browser/open-in-user-browser.js +112 -0
  43. package/src/browser/parity.js +439 -0
  44. package/src/browser/profile-directory.js +102 -0
  45. package/src/browser/real-browser.js +513 -306
  46. package/src/browser/restrictions.js +148 -0
  47. package/src/browser/system-browser.js +232 -0
  48. package/src/browser/webdriver.js +572 -0
  49. package/src/cli/args.js +253 -0
  50. package/src/cli/commands.js +332 -0
  51. package/src/cli/dispatcher.js +99 -0
  52. package/src/cli/handle-methods.js +136 -0
  53. package/src/cli/handles.js +273 -0
  54. package/src/cli/high-level-methods.js +310 -0
  55. package/src/cli/main.js +67 -0
  56. package/src/cli/modules.js +100 -0
  57. package/src/cli/rpc-error.js +77 -0
  58. package/src/cli/script.js +77 -0
  59. package/src/cli/serve.js +144 -0
  60. package/src/cli/sessions.js +195 -0
  61. package/src/core/constants.js +7 -1
  62. package/src/core/engine-adapter.js +107 -1
  63. package/src/core/engine-detection.js +18 -3
  64. package/src/core/navigation-safety.js +4 -0
  65. package/src/core/webdriver-conversions.js +347 -0
  66. package/src/core/webdriver-events.js +229 -0
  67. package/src/core/webdriver-page.js +716 -0
  68. package/src/downloads/attach.js +6 -0
  69. package/src/downloads/manager.js +9 -0
  70. package/src/downloads/sources.js +71 -0
  71. package/src/downloads/staging.js +21 -9
  72. package/src/elements/selectors.js +3 -2
  73. package/src/elements/visibility.js +4 -0
  74. package/src/exports.js +61 -2
  75. package/src/factory.js +19 -4
  76. package/src/fingerprint/apply.js +6 -0
  77. package/src/fingerprint/automation-parity.js +7 -8
  78. package/src/fingerprint/limitations.js +1 -1
  79. package/src/fingerprint/limitations.json +45 -2
  80. package/src/parity/harness.js +303 -0
  81. package/src/parity/probe.js +838 -0
  82. package/src/parity/version-page.js +144 -0
  83. package/src/traces/bundle.js +10 -2
  84. package/src/traces/recorder.js +16 -3
  85. package/src/utilities/subprocess.js +199 -0
  86. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  87. package/tests/e2e/cli.e2e.test.js +160 -0
  88. package/tests/e2e/downloads.e2e.test.js +79 -2
  89. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  90. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  91. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  92. package/tests/e2e/playwright.e2e.test.js +25 -17
  93. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  94. package/tests/e2e/webdriver.e2e.test.js +239 -0
  95. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  96. package/tests/helpers/cli-fakes.js +214 -0
  97. package/tests/helpers/declared-api.js +80 -0
  98. package/tests/helpers/e2e-browser.js +40 -14
  99. package/tests/helpers/fake-chrome.js +124 -0
  100. package/tests/helpers/migration-fixtures.js +242 -0
  101. package/tests/helpers/temp-directory.js +24 -0
  102. package/tests/helpers/trace-fixtures.js +18 -0
  103. package/tests/helpers/webdriver-mocks.js +183 -0
  104. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  105. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  106. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  107. package/tests/unit/browser/attach/websocket.test.js +240 -0
  108. package/tests/unit/browser/browser-cookies.test.js +2 -32
  109. package/tests/unit/browser/cdp-session.test.js +162 -0
  110. package/tests/unit/browser/connector.test.js +23 -0
  111. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  112. package/tests/unit/browser/launch-options.test.js +41 -22
  113. package/tests/unit/browser/launcher.test.js +281 -102
  114. package/tests/unit/browser/media.test.js +13 -1
  115. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  116. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  117. package/tests/unit/browser/migration/cookies.test.js +94 -0
  118. package/tests/unit/browser/migration/extensions.test.js +104 -0
  119. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  120. package/tests/unit/browser/migration/firefox.test.js +139 -0
  121. package/tests/unit/browser/migration/history.test.js +48 -0
  122. package/tests/unit/browser/migration/index.test.js +117 -0
  123. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  124. package/tests/unit/browser/migration/passwords.test.js +144 -0
  125. package/tests/unit/browser/migration/preferences.test.js +77 -0
  126. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  127. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  128. package/tests/unit/browser/parity.test.js +281 -0
  129. package/tests/unit/browser/real-browser.test.js +458 -33
  130. package/tests/unit/browser/webdriver.test.js +473 -0
  131. package/tests/unit/cli/bin.test.js +69 -0
  132. package/tests/unit/cli/declared-api.test.js +49 -0
  133. package/tests/unit/cli/dispatcher.test.js +429 -0
  134. package/tests/unit/cli/handles.test.js +170 -0
  135. package/tests/unit/cli/main.test.js +409 -0
  136. package/tests/unit/cli/serve.test.js +167 -0
  137. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  138. package/tests/unit/core/webdriver-page.test.js +534 -0
  139. package/tests/unit/downloads/manager.test.js +82 -9
  140. package/tests/unit/downloads/staging.test.js +24 -2
  141. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  142. package/tests/unit/fingerprint/limitations.test.js +1 -1
  143. package/tests/unit/parity/harness.test.js +138 -0
  144. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  145. package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
  146. package/tests/unit/scripts/feature-parity.test.js +221 -0
  147. package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
  148. package/tests/unit/scripts/pre-commit-config.test.js +2 -1
  149. package/tests/unit/traces/bundle.test.js +24 -3
  150. package/tests/unit/traces/links.test.js +7 -0
  151. package/tests/unit/traces/reader.test.js +11 -4
  152. package/tests/unit/traces/recorder.test.js +4 -0
  153. package/tests/unit/traces/viewer.test.js +4 -0
  154. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  155. 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
+ });
@@ -271,22 +271,95 @@ describe('download manager (issue #88)', () => {
271
271
  },
272
272
  });
273
273
 
274
+ /**
275
+ * Describe the CDP command that sends downloads to this test's staging path.
276
+ *
277
+ * @param {string} [browserContextId] - Context to configure
278
+ * @returns {Object} Expected CDP command
279
+ */
280
+ const downloadBehaviorCommand = (browserContextId) => ({
281
+ method: 'Browser.setDownloadBehavior',
282
+ params: {
283
+ behavior: 'allowAndName',
284
+ downloadPath: path.join(root, STAGING_DIRECTORY),
285
+ eventsEnabled: true,
286
+ ...(browserContextId ? { browserContextId } : {}),
287
+ },
288
+ });
289
+
274
290
  it('should ask Chromium to report downloads it did not automate', async () => {
275
291
  const session = createFakeCdpSession();
276
292
  manager = await cdpManagerOver(session);
277
293
 
278
- assert.deepStrictEqual(session.sent, [
279
- {
280
- method: 'Browser.setDownloadBehavior',
281
- params: {
282
- behavior: 'allowAndName',
283
- downloadPath: path.join(root, STAGING_DIRECTORY),
284
- eventsEnabled: true,
285
- },
286
- },
294
+ assert.deepStrictEqual(session.sent, [downloadBehaviorCommand()]);
295
+ });
296
+
297
+ it('should scope download behavior to the page browser context', async () => {
298
+ // Issue #97: browser.newPage() and browser.newContext() create a
299
+ // non-default Chromium context. Browser download events are global, but
300
+ // the path configured without this id belongs to the default context.
301
+ const browserSession = createFakeCdpSession();
302
+ browserSession.send = async (method, params) => {
303
+ browserSession.sent.push({ method, params });
304
+ if (method === 'Target.getBrowserContexts') {
305
+ return { browserContextIds: ['context-from-target'] };
306
+ }
307
+ return undefined;
308
+ };
309
+ const pageSession = createFakeCdpSession();
310
+ pageSession.send = async (method, params) => {
311
+ pageSession.sent.push({ method, params });
312
+ return {
313
+ targetInfo: { browserContextId: 'context-from-target' },
314
+ };
315
+ };
316
+ let pageSessionDetached = false;
317
+ pageSession.detach = async () => {
318
+ pageSessionDetached = true;
319
+ };
320
+ const browser = {
321
+ newBrowserCDPSession: async () => browserSession,
322
+ };
323
+ const browserContext = {
324
+ browser: () => browser,
325
+ newCDPSession: async () => pageSession,
326
+ };
327
+ const page = { context: () => browserContext };
328
+
329
+ manager = await createDownloadManager({
330
+ engine: 'playwright',
331
+ browser: browserContext,
332
+ page,
333
+ directory: root,
334
+ });
335
+
336
+ assert.deepStrictEqual(pageSession.sent, [
337
+ { method: 'Target.getTargetInfo', params: undefined },
338
+ ]);
339
+ assert.strictEqual(pageSessionDetached, true);
340
+ assert.deepStrictEqual(browserSession.sent, [
341
+ { method: 'Target.getBrowserContexts', params: undefined },
342
+ downloadBehaviorCommand('context-from-target'),
287
343
  ]);
288
344
  });
289
345
 
346
+ it('should keep a persistent context on default download behavior', async () => {
347
+ const session = createFakeCdpSession();
348
+ const persistentContext = {
349
+ browser: () => null,
350
+ newCDPSession: async () => session,
351
+ };
352
+
353
+ manager = await createDownloadManager({
354
+ engine: 'playwright',
355
+ browser: persistentContext,
356
+ page: { context: () => persistentContext },
357
+ directory: root,
358
+ });
359
+
360
+ assert.deepStrictEqual(session.sent, [downloadBehaviorCommand()]);
361
+ });
362
+
290
363
  it('should place a manually started download like an automated one', async () => {
291
364
  const session = createFakeCdpSession();
292
365
  manager = await cdpManagerOver(session);
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
 
6
6
  import {
7
7
  DEFAULT_STAGING_POLL_INTERVAL,
8
+ DEFAULT_STAGING_STABILITY,
8
9
  DEFAULT_STAGING_TIMEOUT,
9
10
  describeStagingTimeout,
10
11
  STAGING_IN_PROGRESS_SUFFIXES,
@@ -61,11 +62,31 @@ describe('waiting for staged bytes (issue #92)', () => {
61
62
  clearInterval(grow);
62
63
 
63
64
  assert.strictEqual(settled.ready, true, settled.reason ?? '');
64
- // Two equal readings are what ends the wait, so the file had stopped
65
- // growing before it was declared ready.
65
+ // The quiet period ends the wait, so the file had stopped growing before
66
+ // it was declared ready.
66
67
  assert.strictEqual(settled.bytes, (await fs.stat(staged)).size);
67
68
  });
68
69
 
70
+ it('should survive a pause between writes that is longer than one poll', async () => {
71
+ const staged = path.join(directory.path, 'guid-paused');
72
+ await fs.writeFile(staged, 'first');
73
+ const resume = setTimeout(() => {
74
+ fs.appendFile(staged, '-second').catch(() => {});
75
+ }, 25);
76
+
77
+ const settled = await waitForStagedFile({
78
+ path: staged,
79
+ timeout: 1000,
80
+ interval: 5,
81
+ stability: 60,
82
+ });
83
+ clearTimeout(resume);
84
+
85
+ assert.strictEqual(settled.ready, true, settled.reason ?? '');
86
+ assert.strictEqual(settled.bytes, 'first-second'.length);
87
+ assert.ok(settled.waitedMs >= 60);
88
+ });
89
+
69
90
  it('should not claim a file whose partial sibling is still being written', async () => {
70
91
  const staged = path.join(directory.path, 'guid-partial');
71
92
  await fs.writeFile(staged, 'looks done');
@@ -111,6 +132,7 @@ describe('waiting for staged bytes (issue #92)', () => {
111
132
  it('should ship defaults a download can actually finish within', () => {
112
133
  assert.ok(DEFAULT_STAGING_TIMEOUT >= 1000);
113
134
  assert.ok(DEFAULT_STAGING_POLL_INTERVAL > 0);
135
+ assert.ok(DEFAULT_STAGING_STABILITY > DEFAULT_STAGING_POLL_INTERVAL);
114
136
  assert.ok(DEFAULT_STAGING_POLL_INTERVAL < DEFAULT_STAGING_TIMEOUT);
115
137
  assert.ok(STAGING_IN_PROGRESS_SUFFIXES.includes('.crdownload'));
116
138
  });