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,716 @@
1
+ /**
2
+ * WebDriverPage - a Puppeteer-shaped page over a selenium-webdriver session
3
+ * (issue #104).
4
+ *
5
+ * Browser Commander's element, navigation and interaction helpers talk to a
6
+ * page object: `page.evaluate`, `page.$`, `page.keyboard`, `page.on(...)` and so
7
+ * on. Rather than teaching every helper a third dialect, the selenium engine
8
+ * hands them this facade, which speaks the Puppeteer page API on top of W3C
9
+ * WebDriver (classic commands) and WebDriver BiDi (events, preload scripts,
10
+ * full-page screenshots). The `SeleniumAdapter` in `engine-adapter.js` then
11
+ * only overrides the element operations where WebDriver has a better native
12
+ * command.
13
+ *
14
+ * Elements are the driver's own `WebElement`s. They cross into
15
+ * `page.evaluate()` as DOM nodes and come back out as `WebElement`s, which is
16
+ * what WebDriver's script serialization does natively.
17
+ *
18
+ * What WebDriver cannot do is listed in the shared limitations catalogue
19
+ * (`webdriver-*` entries) and fails with an error naming the entry.
20
+ */
21
+
22
+ import { EventEmitter } from 'node:events';
23
+ import { writeFile } from 'node:fs/promises';
24
+ import { createRequire } from 'node:module';
25
+ import {
26
+ buildEvaluateScript,
27
+ fromWebDriverCookie,
28
+ toPrintOptions,
29
+ toScriptLiteral,
30
+ toWebDriverChord,
31
+ toWebDriverCookie,
32
+ toWebDriverKey,
33
+ } from './webdriver-conversions.js';
34
+ import { createWebDriverEventBridge } from './webdriver-events.js';
35
+
36
+ const require = createRequire(import.meta.url);
37
+
38
+ /** Mouse buttons in WebDriver input actions. */
39
+ const MOUSE_BUTTONS = Object.freeze({ left: 0, middle: 1, right: 2 });
40
+
41
+ const POLL_INTERVAL_MS = 100;
42
+
43
+ /** Error thrown when a wait runs out of time, named like Puppeteer's. */
44
+ export class WebDriverTimeoutError extends Error {
45
+ constructor(message) {
46
+ super(message);
47
+ this.name = 'TimeoutError';
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Open the BiDi connection selenium-webdriver keeps per driver.
53
+ *
54
+ * @param {Object} driver - selenium-webdriver WebDriver
55
+ * @returns {Promise<Object>} The BiDi connection (`send`, `addCallback`)
56
+ */
57
+ export async function connectBidi(driver) {
58
+ try {
59
+ const { Domain } = require('selenium-webdriver/bidi/domain');
60
+ return await Domain.connect(driver);
61
+ } catch (error) {
62
+ // Releases before bidi/domain only have the (now deprecated) getBidi().
63
+ if (error?.code === 'MODULE_NOT_FOUND' && driver.getBidi) {
64
+ return driver.getBidi();
65
+ }
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Whether a value is a selenium-webdriver WebDriver (or a stand-in with the
72
+ * same shape).
73
+ *
74
+ * @param {Object} value
75
+ * @returns {boolean}
76
+ */
77
+ export function isWebDriver(value) {
78
+ return (
79
+ typeof value?.findElements === 'function' &&
80
+ typeof value?.executeScript === 'function' &&
81
+ typeof value?.getCurrentUrl === 'function'
82
+ );
83
+ }
84
+
85
+ /**
86
+ * A page facade over a selenium-webdriver session.
87
+ */
88
+ export class WebDriverPage extends EventEmitter {
89
+ /**
90
+ * @param {Object} driver - selenium-webdriver WebDriver
91
+ * @param {Object} [options]
92
+ * @param {Function} [options.connectBidi] - Opens the BiDi connection (tests)
93
+ * @param {Function} [options.log] - Receives diagnostic messages
94
+ */
95
+ constructor(driver, options = {}) {
96
+ super();
97
+ if (!isWebDriver(driver)) {
98
+ throw new TypeError('WebDriverPage needs a selenium-webdriver WebDriver');
99
+ }
100
+ this.driver = driver;
101
+ this.engine = 'selenium';
102
+ this.isWebDriverPage = true;
103
+ this._url = 'about:blank';
104
+ this._closed = false;
105
+ this._connectBidi = options.connectBidi ?? connectBidi;
106
+ this._log = options.log ?? (() => {});
107
+ this._bidi = undefined; // undefined: not checked yet; null: classic session
108
+ this._contextId = undefined;
109
+ this._mouse = { x: 0, y: 0 };
110
+ this._events = createWebDriverEventBridge(this);
111
+ this._mainFrame = this._createMainFrame();
112
+ this.keyboard = this._createKeyboard();
113
+ this.mouse = this._createMouse();
114
+ }
115
+
116
+ // ==========================================================================
117
+ // Event bridge
118
+ // ==========================================================================
119
+
120
+ on(eventName, listener) {
121
+ super.on(eventName, listener);
122
+ this._events.subscribe(eventName);
123
+ return this;
124
+ }
125
+
126
+ addListener(eventName, listener) {
127
+ return this.on(eventName, listener);
128
+ }
129
+
130
+ prependListener(eventName, listener) {
131
+ super.prependListener(eventName, listener);
132
+ this._events.subscribe(eventName);
133
+ return this;
134
+ }
135
+
136
+ /** Resolves once every BiDi subscription requested so far is active. */
137
+ eventsReady() {
138
+ return this._events.ready();
139
+ }
140
+
141
+ /** @internal Called by the event bridge on a top-level navigation. */
142
+ recordNavigation(url) {
143
+ if (url) {
144
+ this._url = url;
145
+ }
146
+ this.emit('framenavigated', this._mainFrame);
147
+ }
148
+
149
+ /** @internal Called by the event bridge when a subscription failed. */
150
+ recordBridgeError(eventName, error) {
151
+ this._log(`BiDi events for "${eventName}" unavailable: ${error.message}`);
152
+ }
153
+
154
+ // ==========================================================================
155
+ // BiDi
156
+ // ==========================================================================
157
+
158
+ /**
159
+ * The session's BiDi connection, or null for a classic session (no
160
+ * `webSocketUrl` capability).
161
+ *
162
+ * @returns {Promise<Object|null>}
163
+ */
164
+ bidi() {
165
+ if (this._bidi === undefined) {
166
+ this._bidi = (async () => {
167
+ const capabilities = await this.driver.getCapabilities();
168
+ const webSocketUrl = capabilities?.get
169
+ ? capabilities.get('webSocketUrl')
170
+ : capabilities?.webSocketUrl;
171
+ if (typeof webSocketUrl !== 'string') {
172
+ return null;
173
+ }
174
+ return this._connectBidi(this.driver);
175
+ })();
176
+ }
177
+ return this._bidi;
178
+ }
179
+
180
+ /**
181
+ * The BiDi connection, or a clear error naming what needs it.
182
+ *
183
+ * @param {string} feature - What the caller was trying to do
184
+ * @returns {Promise<Object>}
185
+ */
186
+ async requireBidi(feature) {
187
+ const bidi = await this.bidi();
188
+ if (!bidi) {
189
+ throw new Error(
190
+ `${feature} needs WebDriver BiDi; launch with bidi: true (or ask for ` +
191
+ 'the webSocketUrl capability). See the webdriver-classic-has-no-events limitation.'
192
+ );
193
+ }
194
+ return bidi;
195
+ }
196
+
197
+ /**
198
+ * Send a BiDi command and return its result.
199
+ *
200
+ * @param {string} method - e.g. 'browsingContext.navigate'
201
+ * @param {Object} [params]
202
+ * @returns {Promise<Object>}
203
+ */
204
+ async bidiCommand(method, params = {}) {
205
+ const bidi = await this.requireBidi(method);
206
+ const response = await bidi.send({ method, params });
207
+ if (response?.error !== undefined || response?.type === 'error') {
208
+ throw new Error(`${method}: ${response.error}: ${response.message}`);
209
+ }
210
+ return response?.result ?? response;
211
+ }
212
+
213
+ /**
214
+ * Subscribe to a raw BiDi event.
215
+ *
216
+ * @param {string} method - e.g. 'log.entryAdded'
217
+ * @param {Function} handler - Receives the event parameters
218
+ * @returns {Promise<{unsubscribe: Function}>}
219
+ */
220
+ async subscribeBidi(method, handler) {
221
+ const bidi = await this.requireBidi(`Subscribing to ${method}`);
222
+ return bidi.addCallback(method, handler);
223
+ }
224
+
225
+ /**
226
+ * Navigate with BiDi `browsingContext.navigate`, which reports the
227
+ * navigation id and settles at the requested readiness.
228
+ *
229
+ * @param {string} url
230
+ * @param {Object} [options]
231
+ * @param {'none'|'interactive'|'complete'} [options.wait='complete']
232
+ * @returns {Promise<{navigation: (string|null), url: string}>}
233
+ */
234
+ async navigateBidi(url, { wait = 'complete' } = {}) {
235
+ const result = await this.bidiCommand('browsingContext.navigate', {
236
+ context: await this.contextId(),
237
+ url,
238
+ wait,
239
+ });
240
+ this._url = result?.url ?? url;
241
+ return result;
242
+ }
243
+
244
+ /**
245
+ * The browsing context id of this page (its window handle, which WebDriver
246
+ * BiDi uses as the context id).
247
+ *
248
+ * @returns {Promise<string>}
249
+ */
250
+ async contextId() {
251
+ if (this._contextId === undefined) {
252
+ this._contextId = await this.driver.getWindowHandle();
253
+ }
254
+ return this._contextId;
255
+ }
256
+
257
+ /** The context id when already known, otherwise undefined. */
258
+ contextIdSync() {
259
+ return this._contextId;
260
+ }
261
+
262
+ // ==========================================================================
263
+ // Navigation and document
264
+ // ==========================================================================
265
+
266
+ /** The last URL WebDriver reported; refreshed by commands and BiDi events. */
267
+ url() {
268
+ return this._url;
269
+ }
270
+
271
+ /**
272
+ * Ask the browser for the current URL and remember it.
273
+ *
274
+ * @returns {Promise<string>}
275
+ */
276
+ async syncUrl() {
277
+ this._url = await this.driver.getCurrentUrl();
278
+ return this._url;
279
+ }
280
+
281
+ async goto(url, options = {}) {
282
+ if (options.timeout) {
283
+ await this.driver.manage().setTimeouts({ pageLoad: options.timeout });
284
+ }
285
+ await this.driver.get(url);
286
+ await this.syncUrl();
287
+ return null;
288
+ }
289
+
290
+ async setContent(html) {
291
+ await this.driver.executeScript(
292
+ 'document.open(); document.write(arguments[0]); document.close();',
293
+ html
294
+ );
295
+ await this.syncUrl();
296
+ }
297
+
298
+ content() {
299
+ return this.driver.getPageSource();
300
+ }
301
+
302
+ title() {
303
+ return this.driver.getTitle();
304
+ }
305
+
306
+ async reload() {
307
+ await this.driver.navigate().refresh();
308
+ await this.syncUrl();
309
+ return null;
310
+ }
311
+
312
+ async goBack() {
313
+ await this.driver.navigate().back();
314
+ await this.syncUrl();
315
+ return null;
316
+ }
317
+
318
+ async goForward() {
319
+ await this.driver.navigate().forward();
320
+ await this.syncUrl();
321
+ return null;
322
+ }
323
+
324
+ /**
325
+ * WebDriver's Get and click commands already wait for the page load, so
326
+ * there is no pending navigation to wait for: settle on the current URL.
327
+ */
328
+ async waitForNavigation() {
329
+ await this.syncUrl();
330
+ return null;
331
+ }
332
+
333
+ async bringToFront() {
334
+ await this.driver.switchTo().window(await this.contextId());
335
+ }
336
+
337
+ // ==========================================================================
338
+ // Script evaluation
339
+ // ==========================================================================
340
+
341
+ /**
342
+ * Run a function (or expression) in the page, awaiting a returned promise.
343
+ * DOM nodes in `args` may be WebElements; returned nodes come back as
344
+ * WebElements.
345
+ *
346
+ * @param {Function|string} pageFunction
347
+ * @param {...*} args
348
+ * @returns {Promise<*>}
349
+ */
350
+ async evaluate(pageFunction, ...args) {
351
+ const [result, href] = await this.driver.executeScript(
352
+ buildEvaluateScript(pageFunction),
353
+ ...args
354
+ );
355
+ if (typeof href === 'string') {
356
+ this._url = href;
357
+ }
358
+ return result;
359
+ }
360
+
361
+ /**
362
+ * Puppeteer's evaluateOnNewDocument, as a BiDi preload script.
363
+ *
364
+ * @param {Function|string} pageFunction
365
+ * @param {...*} args - JSON-serialisable arguments
366
+ * @returns {Promise<{identifier: string}>}
367
+ */
368
+ async evaluateOnNewDocument(pageFunction, ...args) {
369
+ const source =
370
+ typeof pageFunction === 'function'
371
+ ? `(${pageFunction.toString()})(...${toScriptLiteral(args)})`
372
+ : pageFunction;
373
+ const result = await this.bidiCommand('script.addPreloadScript', {
374
+ functionDeclaration: `() => { ${source}; }`,
375
+ contexts: [await this.contextId()],
376
+ });
377
+ return { identifier: result.script };
378
+ }
379
+
380
+ async removeScriptToEvaluateOnNewDocument(identifier) {
381
+ await this.bidiCommand('script.removePreloadScript', {
382
+ script: identifier,
383
+ });
384
+ }
385
+
386
+ // ==========================================================================
387
+ // Elements
388
+ // ==========================================================================
389
+
390
+ async $(selector) {
391
+ const [element] = await this.driver.findElements({ css: selector });
392
+ return element ?? null;
393
+ }
394
+
395
+ $$(selector) {
396
+ return this.driver.findElements({ css: selector });
397
+ }
398
+
399
+ async $eval(selector, pageFunction, ...args) {
400
+ const element = await this.$(selector);
401
+ if (!element) {
402
+ throw new Error(
403
+ `Error: failed to find element matching selector "${selector}"`
404
+ );
405
+ }
406
+ return this.evaluate(pageFunction, element, ...args);
407
+ }
408
+
409
+ async $$eval(selector, pageFunction, ...args) {
410
+ return this.evaluate(pageFunction, await this.$$(selector), ...args);
411
+ }
412
+
413
+ /**
414
+ * Poll for a selector like Puppeteer's waitForSelector.
415
+ *
416
+ * @param {string} selector - CSS selector
417
+ * @param {Object} [options]
418
+ * @param {boolean} [options.visible=false] - Wait until it is displayed
419
+ * @param {boolean} [options.hidden=false] - Wait until it is gone or hidden
420
+ * @param {number} [options.timeout=30000] - 0 waits forever
421
+ * @returns {Promise<Object|null>} The element (null when waiting for hidden)
422
+ */
423
+ async waitForSelector(selector, options = {}) {
424
+ const { visible = false, hidden = false, timeout = 30000 } = options;
425
+ const deadline = timeout > 0 ? Date.now() + timeout : Infinity;
426
+ for (;;) {
427
+ const element = await this.$(selector);
428
+ const displayed = element
429
+ ? await element.isDisplayed().catch(() => false)
430
+ : false;
431
+ if (hidden && (!element || !displayed)) {
432
+ return null;
433
+ }
434
+ if (!hidden && element && (!visible || displayed)) {
435
+ return element;
436
+ }
437
+ if (Date.now() >= deadline) {
438
+ throw new WebDriverTimeoutError(
439
+ `Waiting for selector \`${selector}\` failed: ${timeout}ms exceeded`
440
+ );
441
+ }
442
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
443
+ }
444
+ }
445
+
446
+ async _required(selector) {
447
+ const element = await this.$(selector);
448
+ if (!element) {
449
+ throw new Error(`No element found for selector: ${selector}`);
450
+ }
451
+ return element;
452
+ }
453
+
454
+ async click(selector, options = {}) {
455
+ const element = await this._required(selector);
456
+ const count = options.clickCount ?? options.count ?? 1;
457
+ const button = MOUSE_BUTTONS[options.button ?? 'left'];
458
+ if (count === 1 && button === MOUSE_BUTTONS.left) {
459
+ await element.click();
460
+ } else {
461
+ let actions = this._actions().move({ origin: element });
462
+ for (let i = 0; i < count; i++) {
463
+ actions = actions.press(button).release(button);
464
+ }
465
+ await actions.perform();
466
+ }
467
+ await this.syncUrl();
468
+ }
469
+
470
+ async type(selector, text) {
471
+ const element = await this._required(selector);
472
+ await element.sendKeys(text);
473
+ }
474
+
475
+ async focus(selector) {
476
+ const element = await this._required(selector);
477
+ await this.evaluate((el) => el.focus(), element);
478
+ }
479
+
480
+ // ==========================================================================
481
+ // Input devices
482
+ // ==========================================================================
483
+
484
+ _actions() {
485
+ return this.driver.actions({ async: true });
486
+ }
487
+
488
+ _createKeyboard() {
489
+ const page = this;
490
+ return {
491
+ async press(key, options = {}) {
492
+ const keys = toWebDriverChord(key);
493
+ let actions = page._actions();
494
+ for (const value of keys) {
495
+ actions = actions.keyDown(value);
496
+ }
497
+ if (options.delay) {
498
+ actions = actions.pause(options.delay);
499
+ }
500
+ for (const value of [...keys].reverse()) {
501
+ actions = actions.keyUp(value);
502
+ }
503
+ await actions.perform();
504
+ await page.syncUrl();
505
+ },
506
+ async type(text, options = {}) {
507
+ if (!options.delay) {
508
+ await page._actions().sendKeys(String(text)).perform();
509
+ return;
510
+ }
511
+ for (const character of String(text)) {
512
+ await page._actions().sendKeys(character).perform();
513
+ await new Promise((resolve) => setTimeout(resolve, options.delay));
514
+ }
515
+ },
516
+ async down(key) {
517
+ await page._actions().keyDown(toWebDriverKey(key)).perform();
518
+ },
519
+ async up(key) {
520
+ await page._actions().keyUp(toWebDriverKey(key)).perform();
521
+ },
522
+ async sendCharacter(character) {
523
+ await page._actions().sendKeys(character).perform();
524
+ },
525
+ };
526
+ }
527
+
528
+ _createMouse() {
529
+ const page = this;
530
+ const at = (x, y) => ({
531
+ x: Math.round(x),
532
+ y: Math.round(y),
533
+ origin: 'viewport',
534
+ });
535
+ return {
536
+ async move(x, y) {
537
+ await page._actions().move(at(x, y)).perform();
538
+ page._mouse = { x, y };
539
+ },
540
+ async click(x, y, options = {}) {
541
+ const button = MOUSE_BUTTONS[options.button ?? 'left'];
542
+ let actions = page._actions().move(at(x, y));
543
+ for (let i = 0; i < (options.clickCount ?? options.count ?? 1); i++) {
544
+ actions = actions.press(button);
545
+ if (options.delay) {
546
+ actions = actions.pause(options.delay);
547
+ }
548
+ actions = actions.release(button);
549
+ }
550
+ await actions.perform();
551
+ page._mouse = { x, y };
552
+ await page.syncUrl();
553
+ },
554
+ async down(options = {}) {
555
+ await page
556
+ ._actions()
557
+ .press(MOUSE_BUTTONS[options.button ?? 'left'])
558
+ .perform();
559
+ },
560
+ async up(options = {}) {
561
+ await page
562
+ ._actions()
563
+ .release(MOUSE_BUTTONS[options.button ?? 'left'])
564
+ .perform();
565
+ },
566
+ async wheel({ deltaX = 0, deltaY = 0 } = {}) {
567
+ const { x, y } = page._mouse;
568
+ await page
569
+ ._actions()
570
+ .scroll(Math.round(x), Math.round(y), deltaX, deltaY, 'viewport')
571
+ .perform();
572
+ },
573
+ };
574
+ }
575
+
576
+ // ==========================================================================
577
+ // Capture
578
+ // ==========================================================================
579
+
580
+ /**
581
+ * Take a screenshot. A viewport PNG is a classic WebDriver command;
582
+ * `fullPage`, `clip` and JPEG/WebP need BiDi `browsingContext.captureScreenshot`.
583
+ *
584
+ * @param {Object} [options] - Puppeteer screenshot options
585
+ * @returns {Promise<Buffer|string>}
586
+ */
587
+ async screenshot(options = {}) {
588
+ const { path, fullPage = false, clip, encoding = 'binary' } = options;
589
+ const type = options.type ?? (path?.match(/\.jpe?g$/i) ? 'jpeg' : 'png');
590
+ let base64;
591
+ if (!fullPage && !clip && type === 'png') {
592
+ base64 = await this.driver.takeScreenshot();
593
+ } else {
594
+ const params = {
595
+ context: await this.contextId(),
596
+ origin: fullPage ? 'document' : 'viewport',
597
+ };
598
+ if (type !== 'png') {
599
+ params.format = {
600
+ type: `image/${type}`,
601
+ ...(options.quality !== undefined
602
+ ? { quality: options.quality / 100 }
603
+ : {}),
604
+ };
605
+ }
606
+ if (clip) {
607
+ params.clip = { type: 'box', ...clip };
608
+ delete params.clip.scale;
609
+ }
610
+ ({ data: base64 } = await this.bidiCommand(
611
+ 'browsingContext.captureScreenshot',
612
+ params
613
+ ));
614
+ }
615
+ const buffer = Buffer.from(base64, 'base64');
616
+ if (path) {
617
+ await writeFile(path, buffer);
618
+ }
619
+ return encoding === 'base64' ? base64 : buffer;
620
+ }
621
+
622
+ /**
623
+ * Print to PDF with the WebDriver Print Page command.
624
+ *
625
+ * @param {Object} [options] - Puppeteer/Playwright pdf options
626
+ * @returns {Promise<Buffer>}
627
+ */
628
+ async pdf(options = {}) {
629
+ const buffer = Buffer.from(
630
+ await this.driver.printPage(toPrintOptions(options)),
631
+ 'base64'
632
+ );
633
+ if (options.path) {
634
+ await writeFile(options.path, buffer);
635
+ }
636
+ return buffer;
637
+ }
638
+
639
+ // ==========================================================================
640
+ // Cookies
641
+ // ==========================================================================
642
+
643
+ async cookies() {
644
+ const cookies = await this.driver.manage().getCookies();
645
+ return cookies.map(fromWebDriverCookie);
646
+ }
647
+
648
+ async setCookie(...cookies) {
649
+ for (const cookie of cookies) {
650
+ await this.driver.manage().addCookie(toWebDriverCookie(cookie));
651
+ }
652
+ }
653
+
654
+ async deleteCookie(...cookies) {
655
+ for (const cookie of cookies) {
656
+ await this.driver.manage().deleteCookie(cookie.name);
657
+ }
658
+ }
659
+
660
+ // ==========================================================================
661
+ // Frames and lifecycle
662
+ // ==========================================================================
663
+
664
+ _createMainFrame() {
665
+ const page = this;
666
+ return {
667
+ url: () => page.url(),
668
+ name: () => '',
669
+ parentFrame: () => null,
670
+ childFrames: () => [],
671
+ isDetached: () => page._closed,
672
+ page: () => page,
673
+ evaluate: (pageFunction, ...args) => page.evaluate(pageFunction, ...args),
674
+ };
675
+ }
676
+
677
+ mainFrame() {
678
+ return this._mainFrame;
679
+ }
680
+
681
+ frames() {
682
+ return [this._mainFrame];
683
+ }
684
+
685
+ isClosed() {
686
+ return this._closed;
687
+ }
688
+
689
+ /** Detach event subscriptions; the session itself stays open. */
690
+ async dispose() {
691
+ await this._events.dispose();
692
+ }
693
+
694
+ /** Close this page's window. */
695
+ async close() {
696
+ await this.dispose();
697
+ await this.driver.close();
698
+ this._closed = true;
699
+ this.emit('close');
700
+ }
701
+ }
702
+
703
+ /**
704
+ * Wrap a WebDriver session and read its current URL and window handle, so
705
+ * `page.url()` is right from the first call.
706
+ *
707
+ * @param {Object} driver - selenium-webdriver WebDriver
708
+ * @param {Object} [options] - WebDriverPage options
709
+ * @returns {Promise<WebDriverPage>}
710
+ */
711
+ export async function createWebDriverPage(driver, options = {}) {
712
+ const page = new WebDriverPage(driver, options);
713
+ await page.contextId();
714
+ await page.syncUrl();
715
+ return page;
716
+ }