| // 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 assert = require('node:assert') |
| const fs = require('node:fs') |
| const os = require('node:os') |
| const path = require('node:path') |
| const util = require('node:util') |
| const error = require('selenium-webdriver/lib/error') |
| const firefox = require('selenium-webdriver/firefox') |
| const { WebExtension } = require('selenium-webdriver') |
| const { WebDriver } = require('selenium-webdriver/lib/webdriver') |
| const { Capabilities } = require('selenium-webdriver/lib/capabilities') |
| const { Name } = require('selenium-webdriver/lib/command') |
| const { Session } = require('selenium-webdriver/lib/session') |
| const Symbols = require('selenium-webdriver/lib/symbols') |
| const { load } = require('selenium-webdriver/io/zip') |
| const { extensionData } = require('selenium-webdriver/lib/web_extension') |
| const { locate } = require('../../lib/test/resources') |
| |
| const EXT_XPI = locate('common/extensions/webextensions-selenium-example.xpi') |
| const EXT_DIR = locate('common/extensions/webextensions-selenium-example') |
| |
| /** Answers each command with the handler registered for its name, recording what it was sent. */ |
| class RecordingExecutor { |
| constructor(handlers = {}) { |
| this.handlers = handlers |
| this.commands = [] |
| } |
| |
| execute(command) { |
| this.commands.push(command) |
| const handler = this.handlers[command.getName()] |
| if (!handler) { |
| return Promise.reject(new error.UnknownCommandError(`unexpected command: ${command.getName()}`)) |
| } |
| return Promise.resolve().then(() => handler(command.getParameters())) |
| } |
| } |
| |
| /** |
| * A driver built directly on an executor, as for a Grid without the Builder. Nothing marks it, |
| * so it counts as a remote end. |
| */ |
| function remoteDriver(handlers) { |
| const executor = new RecordingExecutor(handlers) |
| return { driver: new WebDriver(classicSession(), executor), executor } |
| } |
| |
| /** A driver on a driver service this client started, as chromium/firefox/ie/safari mark one. */ |
| function localDriver() { |
| const executor = new RecordingExecutor() |
| executor[Symbols.localDriverService] = true |
| return { driver: new WebDriver(classicSession(), executor), executor } |
| } |
| |
| function classicSession() { |
| return new Session('session-id', new Capabilities()) |
| } |
| |
| describe('WebExtension', function () { |
| it('wraps the id the browser assigned', function () { |
| assert.strictEqual(new WebExtension('ext@example.com').id, 'ext@example.com') |
| }) |
| |
| it('shows its id when logged, and cannot be changed', function () { |
| const extension = new WebExtension('ext@example.com') |
| assert.strictEqual(util.inspect(extension), "WebExtension { id: 'ext@example.com' }") |
| assert.throws(() => { |
| extension.id = 'other' |
| }, TypeError) |
| }) |
| |
| describe('WebDriver#installWebExtension', function () { |
| it('raises without BiDi rather than silently doing nothing', async function () { |
| const executor = new RecordingExecutor() |
| const driver = new WebDriver(classicSession(), executor) |
| |
| await assert.rejects(driver.installWebExtension(EXT_DIR), error.UnsupportedOperationError) |
| assert.deepStrictEqual(executor.commands, []) |
| }) |
| }) |
| |
| describe('install options', function () { |
| function bidiSession(browserName) { |
| return new Session('session-id', new Capabilities({ browserName, webSocketUrl: 'ws://127.0.0.1/session' })) |
| } |
| |
| it("rejects Firefox's options on another browser rather than dropping them", async function () { |
| const executor = new RecordingExecutor() |
| const driver = new WebDriver(bidiSession('chrome'), executor) |
| |
| await assert.rejects( |
| driver.installWebExtension(EXT_DIR, { permanent: true }), |
| (err) => |
| err instanceof error.InvalidArgumentError && /chrome supports no options; got "permanent"/.test(err.message), |
| ) |
| assert.deepStrictEqual(executor.commands, []) |
| }) |
| |
| it('rejects a misspelled Firefox option', async function () { |
| const driver = new firefox.Driver(classicSession(), new RecordingExecutor()) |
| await assert.rejects( |
| driver.installWebExtension(EXT_XPI, { permenant: true }), |
| /Firefox supports only permanent, allowPrivateBrowsing; got "permenant"/, |
| ) |
| }) |
| |
| it('rejects a non-boolean option value', async function () { |
| const driver = new firefox.Driver(classicSession(), new RecordingExecutor()) |
| await assert.rejects(driver.installWebExtension(EXT_XPI, { permanent: 'yes' }), error.InvalidArgumentError) |
| }) |
| |
| it('treats an option set to undefined as not given', async function () { |
| const executor = new RecordingExecutor({ 'install addon': () => 'ext@example.com' }) |
| const driver = new firefox.Driver(classicSession(), executor) |
| |
| await driver.installWebExtension(EXT_XPI, { permanent: undefined }) |
| |
| assert.strictEqual(Object.hasOwn(executor.commands[0].getParameters(), 'temporary'), false) |
| }) |
| }) |
| |
| describe('WebDriver#uninstallWebExtension', function () { |
| it('rejects a raw id', async function () { |
| const driver = new WebDriver(classicSession(), new RecordingExecutor()) |
| await assert.rejects(driver.uninstallWebExtension('ext@example.com'), error.InvalidArgumentError) |
| }) |
| |
| it('raises without BiDi', async function () { |
| const driver = new WebDriver(classicSession(), new RecordingExecutor()) |
| await assert.rejects( |
| driver.uninstallWebExtension(new WebExtension('ext@example.com')), |
| error.UnsupportedOperationError, |
| ) |
| }) |
| }) |
| |
| describe('firefox.Driver without BiDi', function () { |
| function installAddonExecutor(id = 'ext@example.com') { |
| return new RecordingExecutor({ 'install addon': () => id, 'uninstall addon': () => null }) |
| } |
| |
| it('installs an archive through the classic endpoint', async function () { |
| const executor = installAddonExecutor() |
| const driver = new firefox.Driver(classicSession(), executor) |
| |
| const extension = await driver.installWebExtension(EXT_XPI) |
| |
| assert.strictEqual(extension.id, 'ext@example.com') |
| assert.deepStrictEqual(executor.commands[0].getParameters(), { |
| sessionId: 'session-id', |
| addon: fs.readFileSync(EXT_XPI).toString('base64'), |
| }) |
| }) |
| |
| it('maps permanent and allowPrivateBrowsing onto the classic parameters', async function () { |
| const executor = installAddonExecutor() |
| const driver = new firefox.Driver(classicSession(), executor) |
| |
| await driver.installWebExtension(EXT_XPI, { permanent: false, allowPrivateBrowsing: true }) |
| |
| const params = executor.commands[0].getParameters() |
| assert.strictEqual(params.temporary, true) |
| assert.strictEqual(params.allowPrivateBrowsing, true) |
| }) |
| |
| it('zips a directory', async function () { |
| const executor = installAddonExecutor() |
| const driver = new firefox.Driver(classicSession(), executor) |
| |
| await driver.installWebExtension(EXT_DIR) |
| |
| const zip = await loadBase64Zip(executor.commands[0].getParameters().addon) |
| assert.ok(zip.has('manifest.json')) |
| }) |
| |
| it('passes base64 bytes through unchanged', async function () { |
| const executor = installAddonExecutor() |
| const driver = new firefox.Driver(classicSession(), executor) |
| const encoded = fs.readFileSync(EXT_XPI).toString('base64') |
| |
| await driver.installWebExtension(encoded) |
| |
| assert.strictEqual(executor.commands[0].getParameters().addon, encoded) |
| }) |
| |
| it('rejects a path that does not exist', async function () { |
| const driver = new firefox.Driver(classicSession(), installAddonExecutor()) |
| await assert.rejects(driver.installWebExtension('/no/such/extension.xpi'), error.InvalidArgumentError) |
| }) |
| |
| it('rejects a mistyped relative path rather than sending it as base64', async function () { |
| const executor = installAddonExecutor() |
| const driver = new firefox.Driver(classicSession(), executor) |
| |
| // Every character here is also valid base64. |
| await assert.rejects( |
| driver.installWebExtension('extensions/myext'), |
| /No such file or directory, and not base64 of a packed extension.*: extensions\/myext$/, |
| ) |
| assert.deepStrictEqual(executor.commands, []) |
| }) |
| |
| it('rejects base64 that is not a packed extension', async function () { |
| const driver = new firefox.Driver(classicSession(), installAddonExecutor()) |
| await assert.rejects( |
| driver.installWebExtension(Buffer.from('not an extension').toString('base64')), |
| error.InvalidArgumentError, |
| ) |
| }) |
| |
| for (const [label, value] of [ |
| ['undefined', undefined], |
| ['an empty string', ''], |
| ['a Buffer', fs.readFileSync(EXT_XPI)], |
| ]) { |
| it(`rejects ${label} with an argument error`, async function () { |
| const driver = new firefox.Driver(classicSession(), installAddonExecutor()) |
| await assert.rejects(driver.installWebExtension(value), /expects a path or a base64 string/) |
| }) |
| } |
| |
| it('truncates a long rejected value in the error', async function () { |
| const driver = new firefox.Driver(classicSession(), installAddonExecutor()) |
| await assert.rejects(driver.installWebExtension('a'.repeat(500)), (err) => err.message.length < 200) |
| }) |
| |
| it('uninstalls through the classic endpoint', async function () { |
| const executor = installAddonExecutor() |
| const driver = new firefox.Driver(classicSession(), executor) |
| |
| await driver.uninstallWebExtension(new WebExtension('ext@example.com')) |
| |
| assert.strictEqual(executor.commands[0].getName(), 'uninstall addon') |
| assert.strictEqual(executor.commands[0].getParameters().id, 'ext@example.com') |
| }) |
| }) |
| |
| describe('extensionData', function () { |
| it('sends an archive inline as base64', async function () { |
| const { driver, executor } = remoteDriver() |
| const data = await extensionData(driver, EXT_XPI) |
| assert.deepStrictEqual(executor.commands, []) |
| assert.deepStrictEqual(data, { type: 'base64', value: fs.readFileSync(EXT_XPI).toString('base64') }) |
| }) |
| |
| it('uploads a directory to a remote end and references the remote path', async function () { |
| const { driver, executor } = remoteDriver({ [Name.UPLOAD_FILE]: () => '/remote/upload/ext' }) |
| |
| const data = await extensionData(driver, EXT_DIR) |
| |
| assert.deepStrictEqual(data, { type: 'path', path: '/remote/upload/ext' }) |
| // The upload endpoint returns its archive's single top-level entry, so the directory |
| // itself must be that entry. |
| const zip = await loadBase64Zip(executor.commands[0].getParameters().file) |
| assert.ok(zip.has(`${path.basename(EXT_DIR)}/manifest.json`)) |
| }) |
| |
| it('references a directory in place on a local driver service', async function () { |
| const { driver, executor } = localDriver() |
| const data = await extensionData(driver, EXT_DIR) |
| assert.deepStrictEqual(data, { type: 'path', path: path.resolve(EXT_DIR) }) |
| assert.deepStrictEqual(executor.commands, []) |
| }) |
| |
| it('surfaces an upload failure', async function () { |
| const { driver } = remoteDriver({ |
| [Name.UPLOAD_FILE]: () => { |
| throw new error.WebDriverError('disk full') |
| }, |
| }) |
| await assert.rejects(extensionData(driver, EXT_DIR), /disk full/) |
| }) |
| |
| for (const [remoteEnd, rejection] of [ |
| ['chromedriver', new error.UnknownCommandError('unknown command: unknown command: session/1/se/file')], |
| ['geckodriver', new error.WebDriverError('HTTP method not allowed')], |
| ['a remote end answering 404 without a JSON body', new error.UnsupportedOperationError('uploadFile: Not Found')], |
| ]) { |
| it(`explains a directory needs file uploads when ${remoteEnd} has none`, async function () { |
| const { driver } = remoteDriver({ |
| [Name.UPLOAD_FILE]: () => { |
| throw rejection |
| }, |
| }) |
| await assert.rejects(extensionData(driver, EXT_DIR), (err) => { |
| assert.ok(err instanceof error.UnsupportedOperationError) |
| assert.match(err.message, /remote end that accepts file uploads, such as the Selenium Grid/) |
| assert.strictEqual(err.cause, rejection) |
| return true |
| }) |
| }) |
| } |
| }) |
| }) |
| |
| async function loadBase64Zip(encoded) { |
| const tmp = path.join(os.tmpdir(), `webext-${process.pid}-${Date.now()}.zip`) |
| fs.writeFileSync(tmp, Buffer.from(encoded, 'base64')) |
| try { |
| return await load(tmp) |
| } finally { |
| fs.rmSync(tmp) |
| } |
| } |