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.
- package/CHANGELOG.md +145 -0
- package/README.md +216 -32
- package/bin/browser-commander.js +16 -0
- package/eslint.config.js +10 -0
- package/examples/react-test-app/src/App.jsx +24 -23
- package/extension/background.js +14 -0
- package/extension/manifest.json +12 -0
- package/extension/relay-handler.js +353 -0
- package/package.json +19 -4
- package/src/browser/attach/differences.js +131 -0
- package/src/browser/attach/extension-relay.js +402 -0
- package/src/browser/attach/index.js +81 -0
- package/src/browser/attach/snapshot-launch.js +146 -0
- package/src/browser/attach/snapshot.js +374 -0
- package/src/browser/attach/websocket.js +615 -0
- package/src/browser/browser-cookie-cache.js +12 -17
- package/src/browser/browser-cookie-credentials.js +6 -9
- package/src/browser/browser-cookies.js +5 -2
- package/src/browser/cdp-session.js +94 -0
- package/src/browser/connector.js +42 -4
- package/src/browser/debugging-port.js +155 -0
- package/src/browser/launch-options.js +63 -52
- package/src/browser/launch-restrictions.json +147 -0
- package/src/browser/launcher.js +329 -133
- package/src/browser/media.js +5 -0
- package/src/browser/migration/bookmarks.js +73 -0
- package/src/browser/migration/chromium-crypto.js +89 -0
- package/src/browser/migration/cookies.js +155 -0
- package/src/browser/migration/extensions.js +187 -0
- package/src/browser/migration/firefox-bookmarks.js +144 -0
- package/src/browser/migration/firefox-der.js +84 -0
- package/src/browser/migration/firefox-nss.js +281 -0
- package/src/browser/migration/firefox.js +334 -0
- package/src/browser/migration/fs-utils.js +51 -0
- package/src/browser/migration/history.js +103 -0
- package/src/browser/migration/index.js +307 -0
- package/src/browser/migration/os-crypt-keys.js +106 -0
- package/src/browser/migration/passwords.js +171 -0
- package/src/browser/migration/preferences.js +132 -0
- package/src/browser/migration/sqlite-snapshot.js +138 -0
- package/src/browser/open-in-user-browser.js +112 -0
- package/src/browser/parity.js +439 -0
- package/src/browser/profile-directory.js +102 -0
- package/src/browser/real-browser.js +513 -306
- package/src/browser/restrictions.js +148 -0
- package/src/browser/system-browser.js +232 -0
- package/src/browser/webdriver.js +572 -0
- package/src/cli/args.js +253 -0
- package/src/cli/commands.js +332 -0
- package/src/cli/dispatcher.js +99 -0
- package/src/cli/handle-methods.js +136 -0
- package/src/cli/handles.js +273 -0
- package/src/cli/high-level-methods.js +310 -0
- package/src/cli/main.js +67 -0
- package/src/cli/modules.js +100 -0
- package/src/cli/rpc-error.js +77 -0
- package/src/cli/script.js +77 -0
- package/src/cli/serve.js +144 -0
- package/src/cli/sessions.js +195 -0
- package/src/core/constants.js +7 -1
- package/src/core/engine-adapter.js +107 -1
- package/src/core/engine-detection.js +18 -3
- package/src/core/navigation-safety.js +4 -0
- package/src/core/webdriver-conversions.js +347 -0
- package/src/core/webdriver-events.js +229 -0
- package/src/core/webdriver-page.js +716 -0
- package/src/downloads/attach.js +6 -0
- package/src/elements/selectors.js +3 -2
- package/src/elements/visibility.js +4 -0
- package/src/exports.js +61 -2
- package/src/factory.js +19 -4
- package/src/fingerprint/apply.js +6 -0
- package/src/fingerprint/automation-parity.js +7 -8
- package/src/fingerprint/limitations.js +1 -1
- package/src/fingerprint/limitations.json +45 -2
- package/src/parity/harness.js +303 -0
- package/src/parity/probe.js +838 -0
- package/src/parity/version-page.js +144 -0
- package/src/utilities/subprocess.js +199 -0
- package/tests/e2e/api-coverage.e2e.test.js +174 -0
- package/tests/e2e/cli.e2e.test.js +160 -0
- package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
- package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
- package/tests/e2e/measure-parity.e2e.test.js +118 -0
- package/tests/e2e/playwright.e2e.test.js +25 -17
- package/tests/e2e/puppeteer.e2e.test.js +17 -23
- package/tests/e2e/webdriver.e2e.test.js +239 -0
- package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
- package/tests/helpers/cli-fakes.js +214 -0
- package/tests/helpers/declared-api.js +80 -0
- package/tests/helpers/e2e-browser.js +40 -14
- package/tests/helpers/fake-chrome.js +124 -0
- package/tests/helpers/migration-fixtures.js +242 -0
- package/tests/helpers/temp-directory.js +24 -0
- package/tests/helpers/webdriver-mocks.js +183 -0
- package/tests/unit/browser/attach/extension-relay.test.js +360 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
- package/tests/unit/browser/attach/snapshot.test.js +390 -0
- package/tests/unit/browser/attach/websocket.test.js +240 -0
- package/tests/unit/browser/browser-cookies.test.js +2 -32
- package/tests/unit/browser/cdp-session.test.js +162 -0
- package/tests/unit/browser/connector.test.js +23 -0
- package/tests/unit/browser/launch-building-blocks.test.js +238 -0
- package/tests/unit/browser/launch-options.test.js +41 -22
- package/tests/unit/browser/launcher.test.js +281 -102
- package/tests/unit/browser/media.test.js +13 -1
- package/tests/unit/browser/migration/bookmarks.test.js +69 -0
- package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
- package/tests/unit/browser/migration/cookies.test.js +94 -0
- package/tests/unit/browser/migration/extensions.test.js +104 -0
- package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
- package/tests/unit/browser/migration/firefox.test.js +139 -0
- package/tests/unit/browser/migration/history.test.js +48 -0
- package/tests/unit/browser/migration/index.test.js +117 -0
- package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
- package/tests/unit/browser/migration/passwords.test.js +144 -0
- package/tests/unit/browser/migration/preferences.test.js +77 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
- package/tests/unit/browser/open-in-user-browser.test.js +123 -0
- package/tests/unit/browser/parity.test.js +281 -0
- package/tests/unit/browser/real-browser.test.js +458 -33
- package/tests/unit/browser/webdriver.test.js +473 -0
- package/tests/unit/cli/bin.test.js +69 -0
- package/tests/unit/cli/declared-api.test.js +49 -0
- package/tests/unit/cli/dispatcher.test.js +429 -0
- package/tests/unit/cli/handles.test.js +170 -0
- package/tests/unit/cli/main.test.js +409 -0
- package/tests/unit/cli/serve.test.js +167 -0
- package/tests/unit/core/webdriver-conversions.test.js +235 -0
- package/tests/unit/core/webdriver-page.test.js +534 -0
- package/tests/unit/fingerprint/automation-parity.test.js +17 -4
- package/tests/unit/fingerprint/limitations.test.js +1 -1
- package/tests/unit/parity/harness.test.js +138 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
- package/tests/unit/scripts/feature-parity.test.js +221 -0
- package/tests/unit/traces/reader.test.js +1 -0
- package/tests/unit/traces/recorder.test.js +1 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- 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
|
+
}
|