test_lib_web_extension_test.js

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