| // 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) | |
| } | |
| } |