UNPKG

selenium-webdriver

Version:

The official WebDriver JavaScript bindings from the Selenium project

777 lines (706 loc) 25 kB
// Licensed to the Software Freedom Conservancy (SFC) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The SFC licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. /** * @fileoverview Defines the {@linkplain Driver WebDriver} client for Firefox. * Before using this module, you must download the latest * [geckodriver release] and ensure it can be found on your system [PATH]. * * Each FirefoxDriver instance will be created with an anonymous profile, * ensuring browser historys do not share session data (cookies, history, cache, * offline storage, etc.) * * __Customizing the Firefox Profile__ * * The profile used for each WebDriver session may be configured using the * {@linkplain Options} class. For example, you may install an extension, like * Firebug: * * const {Builder} = require('selenium-webdriver'); * const firefox = require('selenium-webdriver/firefox'); * * let options = new firefox.Options() * .addExtensions('/path/to/firebug.xpi') * .setPreference('extensions.firebug.showChromeErrors', true); * * let driver = new Builder() * .forBrowser('firefox') * .setFirefoxOptions(options) * .build(); * * The {@linkplain Options} class may also be used to configure WebDriver based * on a pre-existing browser profile: * * let profile = '/usr/local/home/bob/.mozilla/firefox/3fgog75h.testing'; * let options = new firefox.Options().setProfile(profile); * * The FirefoxDriver will _never_ modify a pre-existing profile; instead it will * create a copy for it to modify. By extension, there are certain browser * preferences that are required for WebDriver to function properly and they * will always be overwritten. * * __Using a Custom Firefox Binary__ * * On Windows and MacOS, the FirefoxDriver will search for Firefox in its * default installation location: * * - Windows: C:\Program Files and C:\Program Files (x86). * - MacOS: /Applications/Firefox.app * * For Linux, Firefox will always be located on the PATH: `$(where firefox)`. * * You can provide a custom location for Firefox by setting the binary in the * {@link Options}:setBinary method. * * const {Builder} = require('selenium-webdriver'); * const firefox = require('selenium-webdriver/firefox'); * * let options = new firefox.Options() * .setBinary('/my/firefox/install/dir/firefox'); * let driver = new Builder() * .forBrowser('firefox') * .setFirefoxOptions(options) * .build(); * * __Remote Testing__ * * You may customize the Firefox binary and profile when running against a * remote Selenium server. Your custom profile will be packaged as a zip and * transferred to the remote host for use. The profile will be transferred * _once for each new session_. The performance impact should be minimal if * you've only configured a few extra browser preferences. If you have a large * profile with several extensions, you should consider installing it on the * remote host and defining its path via the {@link Options} class. Custom * binaries are never copied to remote machines and must be referenced by * installation path. * * const {Builder} = require('selenium-webdriver'); * const firefox = require('selenium-webdriver/firefox'); * * let options = new firefox.Options() * .setProfile('/profile/path/on/remote/host') * .setBinary('/install/dir/on/remote/host/firefox'); * * let driver = new Builder() * .forBrowser('firefox') * .usingServer('http://127.0.0.1:4444/wd/hub') * .setFirefoxOptions(options) * .build(); * * [geckodriver release]: https://github.com/mozilla/geckodriver/releases/ * [PATH]: http://en.wikipedia.org/wiki/PATH_%28variable%29 * * @module selenium-webdriver/firefox */ 'use strict' const fs = require('node:fs') const path = require('node:path') const Symbols = require('./lib/symbols') const command = require('./lib/command') const http = require('./http') const io = require('./io') const remote = require('./remote') const webdriver = require('./lib/webdriver') const zip = require('./io/zip') const { Browser, Capabilities, Capability } = require('./lib/capabilities') const { Zip } = require('./io/zip') const { getBinaryPaths } = require('./common/driverFinder') const FIREFOX_CAPABILITY_KEY = 'moz:firefoxOptions' /** * Thrown when there an add-on is malformed. * @final */ class AddonFormatError extends Error { /** @param {string} msg The error message. */ constructor(msg) { super(msg) /** @override */ this.name = this.constructor.name } } /** * Installs an extension to the given directory. * @param {string} extension Path to the xpi extension file to install. * @param {string} dir Path to the directory to install the extension in. * @return {!Promise<string>} A promise for the add-on ID once * installed. */ async function installExtension(extension, dir) { const ext = extension.slice(-4) if (ext !== '.xpi' && ext !== '.zip') { throw Error('File name does not end in ".zip" or ".xpi": ' + ext) } let archive = await zip.load(extension) if (!archive.has('manifest.json')) { throw new AddonFormatError(`Couldn't find manifest.json in ${extension}`) } let buf = await archive.getFile('manifest.json') let parsedJSON = JSON.parse(buf.toString('utf8')) let { browser_specific_settings } = /** @type {{browser_specific_settings:{gecko:{id:string}}}} */ parsedJSON if (browser_specific_settings && browser_specific_settings.gecko) { /* browser_specific_settings is an alternative to applications * It is meant to facilitate cross-browser plugins since Firefox48 * see https://bugzilla.mozilla.org/show_bug.cgi?id=1262005 */ parsedJSON.applications = browser_specific_settings } let { applications } = /** @type {{applications:{gecko:{id:string}}}} */ parsedJSON if (!(applications && applications.gecko && applications.gecko.id)) { throw new AddonFormatError(`Could not find add-on ID for ${extension}`) } await io.copy(extension, `${path.join(dir, applications.gecko.id)}.xpi`) return applications.gecko.id } class Profile { constructor() { /** @private {?string} */ this.template_ = null /** @private {!Array<string>} */ this.extensions_ = [] } addExtensions(/** !Array<string> */ paths) { this.extensions_ = this.extensions_.concat(...paths) } /** * @return {(!Promise<string>|undefined)} a promise for a base64 encoded * profile, or undefined if there's no data to include. */ [Symbols.serialize]() { if (this.template_ || this.extensions_.length) { return buildProfile(this.template_, this.extensions_) } return undefined } } /** * @param {?string} template path to an existing profile to use as a template. * @param {!Array<string>} extensions paths to extensions to install in the new * profile. * @return {!Promise<string>} a promise for the base64 encoded profile. */ async function buildProfile(template, extensions) { let dir = template if (extensions.length) { dir = await io.tmpDir() if (template) { await io.copyDir(/** @type {string} */ (template), dir, /(parent\.lock|lock|\.parentlock)/) } const extensionsDir = path.join(dir, 'extensions') await io.mkdir(extensionsDir) for (let i = 0; i < extensions.length; i++) { await installExtension(extensions[i], extensionsDir) } } let zip = new Zip() return zip .addDir(dir) .then(() => zip.toBuffer()) .then((buf) => buf.toString('base64')) } /** * Configuration options for the FirefoxDriver. */ class Options extends Capabilities { /** * @param {(Capabilities|Map<string, ?>|Object)=} other Another set of * capabilities to initialize this instance from. */ constructor(other) { super(other) this.setBrowserName(Browser.FIREFOX) // Firefox 129 onwards the CDP protocol will not be enabled by default. Setting this preference will enable it. // https://fxdx.dev/deprecating-cdp-support-in-firefox-embracing-the-future-with-webdriver-bidi/. this.setPreference('remote.active-protocols', 3) } /** * @return {!Object} * @private */ firefoxOptions_() { let options = this.get(FIREFOX_CAPABILITY_KEY) if (!options) { options = {} this.set(FIREFOX_CAPABILITY_KEY, options) } return options } /** * @return {!Profile} * @private */ profile_() { let options = this.firefoxOptions_() if (!options.profile) { options.profile = new Profile() } return options.profile } /** * Specify additional command line arguments that should be used when starting * the Firefox browser. * * @param {...(string|!Array<string>)} args The arguments to include. * @return {!Options} A self reference. */ addArguments(...args) { if (args.length) { let options = this.firefoxOptions_() options.args = options.args ? options.args.concat(...args) : args } return this } /** * Sets the initial window size * * @param {{width: number, height: number}} size The desired window size. * @return {!Options} A self reference. * @throws {TypeError} if width or height is unspecified, not a number, or * less than or equal to 0. */ windowSize({ width, height }) { function checkArg(arg) { if (typeof arg !== 'number' || arg <= 0) { throw TypeError('Arguments must be {width, height} with numbers > 0') } } checkArg(width) checkArg(height) return this.addArguments(`--width=${width}`, `--height=${height}`) } /** * Add extensions that should be installed when starting Firefox. * * @param {...string} paths The paths to the extension XPI files to install. * @return {!Options} A self reference. */ addExtensions(...paths) { this.profile_().addExtensions(paths) return this } /** * @param {string} key the preference key. * @param {(string|number|boolean)} value the preference value. * @return {!Options} A self reference. * @throws {TypeError} if either the key or value has an invalid type. */ setPreference(key, value) { if (typeof key !== 'string') { throw TypeError(`key must be a string, but got ${typeof key}`) } if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { throw TypeError(`value must be a string, number, or boolean, but got ${typeof value}`) } let options = this.firefoxOptions_() options.prefs = options.prefs || {} options.prefs[key] = value return this } /** * Sets the path to an existing profile to use as a template for new browser * sessions. This profile will be copied for each new session - changes will * not be applied to the profile itself. * * @param {string} profile The profile to use. * @return {!Options} A self reference. * @throws {TypeError} if profile is not a string. */ setProfile(profile) { if (typeof profile !== 'string') { throw TypeError(`profile must be a string, but got ${typeof profile}`) } this.profile_().template_ = profile return this } /** * Sets the binary to use. The binary may be specified as the path to a * Firefox executable. * * @param {(string)} binary The binary to use. * @return {!Options} A self reference. * @throws {TypeError} If `binary` is an invalid type. */ setBinary(binary) { if (binary instanceof Channel || typeof binary === 'string') { this.firefoxOptions_().binary = binary return this } throw TypeError('binary must be a string path ') } /** * Enables Mobile start up features * * @param {string} androidPackage The package to use * @return {!Options} A self reference */ enableMobile(androidPackage = 'org.mozilla.firefox', androidActivity = null, deviceSerial = null) { this.firefoxOptions_().androidPackage = androidPackage if (androidActivity) { this.firefoxOptions_().androidActivity = androidActivity } if (deviceSerial) { this.firefoxOptions_().deviceSerial = deviceSerial } return this } /** * Enables moz:debuggerAddress for firefox cdp */ enableDebugger() { return this.set('moz:debuggerAddress', true) } /** * Enable bidi connection * @returns {!Capabilities} */ enableBidi() { return this.set('webSocketUrl', true) } } /** * Enum of available command contexts. * * Command contexts are specific to Marionette, and may be used with the * {@link #context=} method. Contexts allow you to direct all subsequent * commands to either "content" (default) or "chrome". The latter gives * you elevated security permissions. * * @enum {string} */ const Context = { CONTENT: 'content', CHROME: 'chrome', } /** * @param {string} file Path to the file to find, relative to the program files * root. * @return {!Promise<?string>} A promise for the located executable. * The promise will resolve to {@code null} if Firefox was not found. */ function findInProgramFiles(file) { let files = [ process.env['PROGRAMFILES'] || 'C:\\Program Files', process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', ].map((prefix) => path.join(prefix, file)) return io.exists(files[0]).then(function (exists) { return exists ? files[0] : io.exists(files[1]).then(function (exists) { return exists ? files[1] : null }) }) } /** @enum {string} */ const ExtensionCommand = { GET_CONTEXT: 'getContext', SET_CONTEXT: 'setContext', INSTALL_ADDON: 'install addon', UNINSTALL_ADDON: 'uninstall addon', FULL_PAGE_SCREENSHOT: 'fullPage screenshot', } /** * Creates a command executor with support for Marionette's custom commands. * @param {!Promise<string>} serverUrl The server's URL. * @return {!command.Executor} The new command executor. */ function createExecutor(serverUrl) { let client = serverUrl.then((url) => new http.HttpClient(url)) let executor = new http.Executor(client) configureExecutor(executor) return executor } /** * Configures the given executor with Firefox-specific commands. * @param {!http.Executor} executor the executor to configure. */ function configureExecutor(executor) { executor.defineCommand(ExtensionCommand.GET_CONTEXT, 'GET', '/session/:sessionId/moz/context') executor.defineCommand(ExtensionCommand.SET_CONTEXT, 'POST', '/session/:sessionId/moz/context') executor.defineCommand(ExtensionCommand.INSTALL_ADDON, 'POST', '/session/:sessionId/moz/addon/install') executor.defineCommand(ExtensionCommand.UNINSTALL_ADDON, 'POST', '/session/:sessionId/moz/addon/uninstall') executor.defineCommand(ExtensionCommand.FULL_PAGE_SCREENSHOT, 'GET', '/session/:sessionId/moz/screenshot/full') } /** * Creates {@link selenium-webdriver/remote.DriverService} instances that manage * a [geckodriver](https://github.com/mozilla/geckodriver) server in a child * process. */ class ServiceBuilder extends remote.DriverService.Builder { /** * @param {string=} opt_exe Path to the server executable to use. If omitted, * the builder will attempt to locate the geckodriver on the system PATH. */ constructor(opt_exe) { super(opt_exe) this.setLoopback(true) // Required. } /** * Enables verbose logging. * * @param {boolean=} opt_trace Whether to enable trace-level logging. By * default, only debug logging is enabled. * @return {!ServiceBuilder} A self reference. */ enableVerboseLogging(opt_trace) { return this.addArguments(opt_trace ? '-vv' : '-v') } } /** * A WebDriver client for Firefox. */ class Driver extends webdriver.WebDriver { /** * Creates a new Firefox session. * * @param {(Options|Capabilities|Object)=} opt_config The * configuration options for this driver, specified as either an * {@link Options} or {@link Capabilities}, or as a raw hash object. * @param {(http.Executor|remote.DriverService)=} opt_executor Either a * pre-configured command executor to use for communicating with an * externally managed remote end (which is assumed to already be running), * or the `DriverService` to use to start the geckodriver in a child * process. * * If an executor is provided, care should e taken not to use reuse it with * other clients as its internal command mappings will be updated to support * Firefox-specific commands. * * _This parameter may only be used with Mozilla's GeckoDriver._ * * @throws {Error} If a custom command executor is provided and the driver is * configured to use the legacy FirefoxDriver from the Selenium project. * @return {!Driver} A new driver instance. */ static createSession(opt_config, opt_executor) { let caps = opt_config instanceof Capabilities ? opt_config : new Options(opt_config) let firefoxBrowserPath = null let executor let onQuit if (opt_executor instanceof http.Executor) { executor = opt_executor configureExecutor(executor) } else if (opt_executor instanceof remote.DriverService) { if (!opt_executor.getExecutable()) { const { driverPath, browserPath } = getBinaryPaths(caps) opt_executor.setExecutable(driverPath) firefoxBrowserPath = browserPath } executor = createExecutor(opt_executor.start()) onQuit = () => opt_executor.kill() } else { let service = new ServiceBuilder().build() if (!service.getExecutable()) { const { driverPath, browserPath } = getBinaryPaths(caps) service.setExecutable(driverPath) firefoxBrowserPath = browserPath } executor = createExecutor(service.start()) onQuit = () => service.kill() } if (firefoxBrowserPath) { const vendorOptions = caps.get(FIREFOX_CAPABILITY_KEY) if (vendorOptions) { vendorOptions['binary'] = firefoxBrowserPath caps.set(FIREFOX_CAPABILITY_KEY, vendorOptions) } else { caps.set(FIREFOX_CAPABILITY_KEY, { binary: firefoxBrowserPath }) } caps.delete(Capability.BROWSER_VERSION) } return /** @type {!Driver} */ (super.createSession(executor, caps, onQuit)) } /** * This function is a no-op as file detectors are not supported by this * implementation. * @override */ setFileDetector() {} /** * Get the context that is currently in effect. * * @return {!Promise<Context>} Current context. */ getContext() { return this.execute(new command.Command(ExtensionCommand.GET_CONTEXT)) } /** * Changes target context for commands between chrome- and content. * * Changing the current context has a stateful impact on all subsequent * commands. The {@link Context.CONTENT} context has normal web * platform document permissions, as if you would evaluate arbitrary * JavaScript. The {@link Context.CHROME} context gets elevated * permissions that lets you manipulate the browser chrome itself, * with full access to the XUL toolkit. * * Use your powers wisely. * * @param {!Promise<void>} ctx The context to switch to. */ setContext(ctx) { return this.execute(new command.Command(ExtensionCommand.SET_CONTEXT).setParameter('context', ctx)) } /** * Installs a new addon with the current session. This function will return an * ID that may later be used to {@linkplain #uninstallAddon uninstall} the * addon. * * * @param {string} path Path on the local filesystem to the web extension to * install. * @param {boolean} temporary Flag indicating whether the extension should be * installed temporarily - gets removed on restart * @return {!Promise<string>} A promise that will resolve to an ID for the * newly installed addon. * @see #uninstallAddon */ async installAddon(path, temporary = false) { let stats = fs.statSync(path) let buf if (stats.isDirectory()) { let zip = new Zip() await zip.addDir(path) buf = await zip.toBuffer('DEFLATE') } else { buf = await io.read(path) } return this.execute( new command.Command(ExtensionCommand.INSTALL_ADDON) .setParameter('addon', buf.toString('base64')) .setParameter('temporary', temporary), ) } /** * Uninstalls an addon from the current browser session's profile. * * @param {(string|!Promise<string>)} id ID of the addon to uninstall. * @return {!Promise} A promise that will resolve when the operation has * completed. * @see #installAddon */ async uninstallAddon(id) { id = await Promise.resolve(id) return this.execute(new command.Command(ExtensionCommand.UNINSTALL_ADDON).setParameter('id', id)) } /** * Take full page screenshot of the visible region * * @return {!Promise<string>} A promise that will be * resolved to the screenshot as a base-64 encoded PNG. */ takeFullPageScreenshot() { return this.execute(new command.Command(ExtensionCommand.FULL_PAGE_SCREENSHOT)) } } /** * Provides methods for locating the executable for a Firefox release channel * on Windows and MacOS. For other systems (i.e. Linux), Firefox will always * be located on the system PATH. * @deprecated Instead of using this class, you should configure the * {@link Options} with the appropriate binary location or let Selenium * Manager handle it for you. * @final */ class Channel { /** * @param {string} darwin The path to check when running on MacOS. * @param {string} win32 The path to check when running on Windows. */ constructor(darwin, win32) { /** @private @const */ this.darwin_ = darwin /** @private @const */ this.win32_ = win32 /** @private {Promise<string>} */ this.found_ = null } /** * Attempts to locate the Firefox executable for this release channel. This * will first check the default installation location for the channel before * checking the user's PATH. The returned promise will be rejected if Firefox * can not be found. * * @return {!Promise<string>} A promise for the location of the located * Firefox executable. */ locate() { if (this.found_) { return this.found_ } let found switch (process.platform) { case 'darwin': found = io.exists(this.darwin_).then((exists) => (exists ? this.darwin_ : io.findInPath('firefox'))) break case 'win32': found = findInProgramFiles(this.win32_).then((found) => found || io.findInPath('firefox.exe')) break default: found = Promise.resolve(io.findInPath('firefox')) break } this.found_ = found.then((found) => { if (found) { // TODO: verify version info. return found } throw Error('Could not locate Firefox on the current system') }) return this.found_ } /** @return {!Promise<string>} */ [Symbols.serialize]() { return this.locate() } } /** * Firefox's developer channel. * @const * @see <https://www.mozilla.org/en-US/firefox/channel/desktop/#developer> */ Channel.DEV = new Channel( '/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox', 'Firefox Developer Edition\\firefox.exe', ) /** * Firefox's beta channel. Note this is provided mainly for convenience as * the beta channel has the same installation location as the main release * channel. * @const * @see <https://www.mozilla.org/en-US/firefox/channel/desktop/#beta> */ Channel.BETA = new Channel('/Applications/Firefox.app/Contents/MacOS/firefox', 'Mozilla Firefox\\firefox.exe') /** * Firefox's release channel. * @const * @see <https://www.mozilla.org/en-US/firefox/desktop/> */ Channel.RELEASE = new Channel('/Applications/Firefox.app/Contents/MacOS/firefox', 'Mozilla Firefox\\firefox.exe') /** * Firefox's nightly release channel. * @const * @see <https://www.mozilla.org/en-US/firefox/channel/desktop/#nightly> */ Channel.NIGHTLY = new Channel('/Applications/Firefox Nightly.app/Contents/MacOS/firefox', 'Nightly\\firefox.exe') // PUBLIC API module.exports = { Channel, Context, Driver, Options, ServiceBuilder, }