UNPKG

selenium-webdriver

Version:

The official WebDriver JavaScript bindings from the Selenium project

242 lines (224 loc) • 8.85 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. 'use strict' const fs = require('node:fs/promises') const path = require('node:path') const command = require('./command') const error = require('./error') const Symbols = require('./symbols') const { Zip } = require('../io/zip') /** * A browser extension installed via {@link ./webdriver.WebDriver#installWebExtension}. * Wraps the identifier the browser assigned; pass it to * {@link ./webdriver.WebDriver#uninstallWebExtension}. */ class WebExtension { /** * @param {string} id identifier assigned to the extension by the browser. * @package */ constructor(id) { /** @const {string} identifier assigned to the extension by the browser. */ this.id = id // A public field, frozen, rather than a private field behind a getter, so logging an // extension shows its id instead of an empty `WebExtension {}`. Object.freeze(this) } } /** * @param {!./webdriver.WebDriver} driver * @return {!Promise<boolean>} whether the session was created with BiDi enabled. */ async function isBidiEnabled(driver) { const caps = await driver.getCapabilities() return Boolean(caps.get('webSocketUrl')) } /** * @param {string} method the driver method that needs BiDi. * @return {!error.UnsupportedOperationError} */ function bidiRequired(method) { return new error.UnsupportedOperationError( `${method} requires BiDi; enable it with options.enableBidi() before creating the session`, ) } /** * Checks the install options against the ones this browser honors, so an option it would drop * (another browser's, or a misspelled one) fails instead of being silently ignored. * @param {*} options the options passed to installWebExtension. * @param {!Array<string>} supported the boolean options this browser honors. * @param {string} browser the browser name, for the error message. * @return {!Object<string, boolean>} the options that were set. * @throws {error.InvalidArgumentError} on an unsupported option or a non-boolean value. */ function checkInstallOptions(options, supported, browser) { if (options === undefined) { return {} } if (typeof options !== 'object' || options === null || Array.isArray(options)) { throw new error.InvalidArgumentError('installWebExtension options must be an object') } const set = {} for (const [key, value] of Object.entries(options)) { if (value === undefined) continue if (!supported.includes(key)) { const allowed = supported.length ? `supports only ${supported.join(', ')}` : 'supports no options' throw new error.InvalidArgumentError(`installWebExtension on ${browser} ${allowed}; got "${key}"`) } if (typeof value !== 'boolean') { throw new error.InvalidArgumentError(`installWebExtension option "${key}" must be a boolean`) } set[key] = value } return set } /** * @param {*} extension the value passed to uninstallWebExtension. * @return {string} the wrapped id. * @throws {error.InvalidArgumentError} if `extension` is not a {@link WebExtension}. */ function extensionId(extension) { if (!(extension instanceof WebExtension)) { throw new error.InvalidArgumentError( 'uninstallWebExtension expects the WebExtension returned by installWebExtension', ) } return extension.id } /** * The extension as base64 bytes: a directory is zipped, a file read, and anything else is * taken to already be base64-encoded. * @param {string} extension * @return {!Promise<string>} */ async function encodeExtension(extension) { checkExtensionArg(extension) const stats = await statOrNull(extension) if (stats?.isDirectory()) { const zip = new Zip() await zip.addDir(extension) return (await zip.toBuffer('DEFLATE')).toString('base64') } if (stats?.isFile()) { return (await fs.readFile(extension)).toString('base64') } return asBase64(extension) } /** * The BiDi `webExtension.ExtensionData` for `extension`. A directory only resolves on the * machine running the browser, so unless the session runs on a driver service this client * started (see {@link Symbols.localDriverService}) it is uploaded first; an archive or base64 * bytes travel inline. * @param {!./webdriver.WebDriver} driver * @param {string} extension * @return {!Promise<import('../bidi/generated/webextension').WebExtensionExtensionData>} */ async function extensionData(driver, extension) { checkExtensionArg(extension) const stats = await statOrNull(extension) if (stats?.isDirectory()) { const dir = path.resolve(extension) const local = driver.getExecutor()[Symbols.localDriverService] === true return { type: 'path', path: local ? dir : await uploadDirectory(driver, dir) } } return { type: 'base64', value: await encodeExtension(extension) } } /** * Uploads `dir` to the remote end and returns the path it was unpacked to there. * @param {!./webdriver.WebDriver} driver * @param {string} dir absolute path of the directory. * @return {!Promise<string>} */ async function uploadDirectory(driver, dir) { // The endpoint returns the path of the archive's single top-level entry, so keep the // directory itself as that entry rather than flattening its contents. const zip = new Zip() await zip.addDir(dir, path.basename(dir)) const encoded = (await zip.toBuffer('DEFLATE')).toString('base64') try { return await driver.execute(new command.Command(command.Name.UPLOAD_FILE).setParameter('file', encoded)) } catch (err) { if (!isUnsupportedCommand(err)) { throw err } const unsupported = new error.UnsupportedOperationError( 'Could not upload the extension directory to the remote end. Installing an unpacked ' + 'extension over a remote session needs a remote end that accepts file uploads, such as ' + 'the Selenium Grid.', ) unsupported.cause = err throw unsupported } } function isUnsupportedCommand(err) { return ( err instanceof error.UnknownCommandError || err instanceof error.UnknownMethodError || err instanceof error.UnsupportedOperationError || // geckodriver answers an unknown endpoint with a bare 405, which decodes to no specific type. (err instanceof error.WebDriverError && err.message === 'HTTP method not allowed') ) } async function statOrNull(file) { try { return await fs.stat(file) } catch (err) { if (err.code === 'ENOENT' || err.code === 'ENAMETOOLONG') { return null } throw err } } function checkExtensionArg(extension) { if (typeof extension !== 'string' || extension === '') { throw new error.InvalidArgumentError( `installWebExtension expects a path or a base64 string, got ${extension === '' ? 'an empty string' : typeof extension}`, ) } } // Leading bytes of a packed extension: a zip (.xpi, .zip) or a Chrome .crx. const ARCHIVE_SIGNATURES = [Buffer.from('PK\x03\x04', 'latin1'), Buffer.from('Cr24', 'latin1')] /** * `value` itself, if it is base64 of a packed extension. Checking the decoded bytes, not just the * alphabet, matters: a mistyped relative path such as `extensions/myext` is valid base64 too, and * sending it would surface as an unrelated error from the browser. * @param {string} value * @return {string} * @throws {error.InvalidArgumentError} if it is neither. */ function asBase64(value) { const bytes = /^[A-Za-z0-9+/]+={0,2}$/.test(value) ? Buffer.from(value, 'base64') : null if (!bytes || !ARCHIVE_SIGNATURES.some((signature) => bytes.subarray(0, signature.length).equals(signature))) { throw new error.InvalidArgumentError( `No such file or directory, and not base64 of a packed extension (.xpi/.zip/.crx): ${truncate(value)}`, ) } return value } function truncate(value, max = 80) { return value.length > max ? `${value.slice(0, max)}...` : value } module.exports = { WebExtension, // Package-internal helpers shared by WebDriver and firefox.Driver; not re-exported from index.js. bidiRequired, checkInstallOptions, encodeExtension, extensionData, extensionId, isBidiEnabled, }