web-ext
Version:
A command line tool to help build, run, and test web extensions
569 lines (553 loc) • 22.8 kB
JavaScript
function _classPrivateMethodInitSpec(e, a) { _checkPrivateRedeclaration(e, a), a.add(e); }
function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
function _classPrivateFieldInitSpec(e, t, a) { _checkPrivateRedeclaration(e, t), t.set(e, a); }
function _checkPrivateRedeclaration(e, t) { if (t.has(e)) throw new TypeError("Cannot initialize the same private elements twice on an object"); }
function _classPrivateFieldGet(s, a) { return s.get(_assertClassBrand(s, a)); }
function _classPrivateFieldSet(s, a, r) { return s.set(_assertClassBrand(s, a), r), r; }
function _assertClassBrand(e, t, n) { if ("function" == typeof e ? e === t : e.has(t)) return arguments.length < 3 ? t : n; throw new TypeError("Private element is not present on this object"); }
/**
* This module provide an ExtensionRunner subclass that manage an extension executed
* in a Chromium-based browser instance.
*/
import fs from 'fs/promises';
import path from 'path';
import { Launcher as ChromeLauncher, launch as defaultChromiumLaunch } from 'chrome-launcher';
import { createLogger } from '../util/logger.js';
import { TempDir } from '../util/temp-dir.js';
import isDirectory from '../util/is-directory.js';
import fileExists from '../util/file-exists.js';
const log = createLogger(import.meta.url);
const EXCLUDED_CHROME_FLAGS = ['--disable-extensions', '--mute-audio', '--disable-component-update'];
export const DEFAULT_CHROME_FLAGS = ChromeLauncher.defaultFlags().filter(flag => !EXCLUDED_CHROME_FLAGS.includes(flag));
// This is a client for the Chrome Devtools protocol. The methods and results
// are documented at https://chromedevtools.github.io/devtools-protocol/tot/
var _receivedData = /*#__PURE__*/new WeakMap();
var _isProcessingMessage = /*#__PURE__*/new WeakMap();
var _lastId = /*#__PURE__*/new WeakMap();
var _deferredResponses = /*#__PURE__*/new WeakMap();
var _disconnected = /*#__PURE__*/new WeakMap();
var _disconnectedPromise = /*#__PURE__*/new WeakMap();
var _resolveDisconnectedPromise = /*#__PURE__*/new WeakMap();
var _ChromeDevtoolsProtocolClient_brand = /*#__PURE__*/new WeakSet();
class ChromeDevtoolsProtocolClient {
constructor(chromiumInstance) {
_classPrivateMethodInitSpec(this, _ChromeDevtoolsProtocolClient_brand);
_classPrivateFieldInitSpec(this, _receivedData, '');
_classPrivateFieldInitSpec(this, _isProcessingMessage, false);
_classPrivateFieldInitSpec(this, _lastId, 0);
_classPrivateFieldInitSpec(this, _deferredResponses, new Map());
_classPrivateFieldInitSpec(this, _disconnected, false);
_classPrivateFieldInitSpec(this, _disconnectedPromise, void 0);
_classPrivateFieldInitSpec(this, _resolveDisconnectedPromise, void 0);
// Print all exchanged CDP messages to ease debugging.
_defineProperty(this, "TEST_LOG_VERBOSE_CDP", process.env.TEST_LOG_VERBOSE_CDP);
// remoteDebuggingPipes is from chrome-launcher, see
// https://github.com/GoogleChrome/chrome-launcher/pull/347
const {
incoming,
outgoing
} = chromiumInstance.remoteDebuggingPipes;
_classPrivateFieldSet(_disconnectedPromise, this, new Promise(resolve => {
_classPrivateFieldSet(_resolveDisconnectedPromise, this, resolve);
}));
if (incoming.closed) {
// Strange. Did Chrome fail to start, or exit on startup?
log.warn('CDP already disconnected at initialization');
_assertClassBrand(_ChromeDevtoolsProtocolClient_brand, this, _finalizeDisconnect).call(this);
return;
}
incoming.on('data', data => {
_classPrivateFieldSet(_receivedData, this, _classPrivateFieldGet(_receivedData, this) + data);
_assertClassBrand(_ChromeDevtoolsProtocolClient_brand, this, _processNextMessage).call(this);
});
incoming.on('error', error => {
log.error(error);
_assertClassBrand(_ChromeDevtoolsProtocolClient_brand, this, _finalizeDisconnect).call(this);
});
incoming.on('close', () => _assertClassBrand(_ChromeDevtoolsProtocolClient_brand, this, _finalizeDisconnect).call(this));
this.outgoingPipe = outgoing;
}
waitUntilDisconnected() {
return _classPrivateFieldGet(_disconnectedPromise, this);
}
async sendCommand(method, params, sessionId = undefined) {
var _this$lastId;
if (_classPrivateFieldGet(_disconnected, this)) {
throw new Error(`CDP disconnected, cannot send: command ${method}`);
}
const message = {
id: _classPrivateFieldSet(_lastId, this, (_this$lastId = _classPrivateFieldGet(_lastId, this), ++_this$lastId)),
method,
params,
sessionId
};
const rawMessage = `${JSON.stringify(message)}\x00`;
if (this.TEST_LOG_VERBOSE_CDP) {
process.stderr.write(`[CDP] [SEND] ${rawMessage}\n`);
}
return new Promise((resolve, reject) => {
// CDP will always send a response.
_classPrivateFieldGet(_deferredResponses, this).set(message.id, {
method,
resolve,
reject
});
this.outgoingPipe.write(rawMessage);
});
}
}
/**
* Implements an IExtensionRunner which manages a Chromium instance.
*/
function _processNextMessage() {
if (_classPrivateFieldGet(_isProcessingMessage, this)) {
return;
}
_classPrivateFieldSet(_isProcessingMessage, this, true);
let end = _classPrivateFieldGet(_receivedData, this).indexOf('\x00');
while (end !== -1) {
const rawMessage = _classPrivateFieldGet(_receivedData, this).slice(0, end);
_classPrivateFieldSet(_receivedData, this, _classPrivateFieldGet(_receivedData, this).slice(end + 1)); // +1 skips \x00.
try {
if (this.TEST_LOG_VERBOSE_CDP) {
process.stderr.write(`[CDP] [RECV] ${rawMessage}\n`);
}
const {
id,
error,
result
} = JSON.parse(rawMessage);
const deferredResponse = _classPrivateFieldGet(_deferredResponses, this).get(id);
if (deferredResponse) {
_classPrivateFieldGet(_deferredResponses, this).delete(id);
if (error) {
const err = new Error(error.message || 'Unexpected CDP response');
deferredResponse.reject(err);
} else {
deferredResponse.resolve(result);
}
} else {
// Dropping events and non-response messages since we don't need it.
}
} catch (e) {
log.error(e);
}
end = _classPrivateFieldGet(_receivedData, this).indexOf('\x00');
}
_classPrivateFieldSet(_isProcessingMessage, this, false);
if (_classPrivateFieldGet(_disconnected, this)) {
for (const {
method,
reject
} of _classPrivateFieldGet(_deferredResponses, this).values()) {
reject(new Error(`CDP connection closed before response to ${method}`));
}
_classPrivateFieldGet(_deferredResponses, this).clear();
_classPrivateFieldGet(_resolveDisconnectedPromise, this).call(this);
}
}
function _finalizeDisconnect() {
if (!_classPrivateFieldGet(_disconnected, this)) {
_classPrivateFieldSet(_disconnected, this, true);
_assertClassBrand(_ChromeDevtoolsProtocolClient_brand, this, _processNextMessage).call(this);
}
}
export class ChromiumExtensionRunner {
cleanupCallbacks;
params;
chromiumInstance;
chromiumLaunch;
// --load-extension is deprecated, but only supported in Chrome 126+, see:
// https://github.com/mozilla/web-ext/issues/3388#issuecomment-2906982117
forceUseDeprecatedLoadExtension;
exiting;
_promiseSetupDone;
constructor(params) {
const {
chromiumLaunch = defaultChromiumLaunch
} = params;
this.params = params;
this.chromiumLaunch = chromiumLaunch;
// We will try to use Extensions.loadUnpacked first (Chrome 126+), and if
// that does not work fall back to --load-extension.
this.forceUseDeprecatedLoadExtension = false;
this.cleanupCallbacks = new Set();
}
// Method exported from the IExtensionRunner interface.
/**
* Returns the runner name.
*/
getName() {
return 'Chromium';
}
async run() {
// Run should never be called more than once.
this._promiseSetupDone = this.setupInstance();
await this._promiseSetupDone;
}
static async isUserDataDir(dirPath) {
const localStatePath = path.join(dirPath, 'Local State');
const defaultPath = path.join(dirPath, 'Default');
// Local State and Default are typical for the user-data-dir
return (await fileExists(localStatePath)) && (await isDirectory(defaultPath));
}
static async isProfileDir(dirPath) {
const securePreferencesPath = path.join(dirPath, 'Secure Preferences');
//Secure Preferences is typical for a profile dir inside a user data dir
return await fileExists(securePreferencesPath);
}
static async getProfilePaths(chromiumProfile) {
if (!chromiumProfile) {
return {
userDataDir: null,
profileDirName: null
};
}
const isProfileDirAndNotUserData = (await ChromiumExtensionRunner.isProfileDir(chromiumProfile)) && !(await ChromiumExtensionRunner.isUserDataDir(chromiumProfile));
if (isProfileDirAndNotUserData) {
const {
dir: userDataDir,
base: profileDirName
} = path.parse(chromiumProfile);
return {
userDataDir,
profileDirName
};
}
return {
userDataDir: chromiumProfile,
profileDirName: null
};
}
/**
* Setup the Chromium Profile and run a Chromium instance.
*/
async setupInstance() {
// NOTE: This function may be called twice, if the user is using an old
// Chrome version (before Chrome 126), because then we have to add a
// command-line flag (--load-extension) to load the extension. For details,
// see:
// https://github.com/mozilla/web-ext/issues/3388#issuecomment-2906982117
// Start chrome pointing it to a given profile dir
const extensions = this.params.extensions.map(({
sourceDir
}) => sourceDir);
const {
chromiumBinary
} = this.params;
log.debug('Starting Chromium instance...');
if (chromiumBinary) {
log.debug(`(chromiumBinary: ${chromiumBinary})`);
}
const chromeFlags = [...DEFAULT_CHROME_FLAGS];
chromeFlags.push('--remote-debugging-pipe');
if (!this.forceUseDeprecatedLoadExtension) {
chromeFlags.push('--enable-unsafe-extension-debugging');
} else {
chromeFlags.push(`--load-extension=${extensions.join(',')}`);
}
if (this.params.args) {
chromeFlags.push(...this.params.args);
}
// eslint-disable-next-line prefer-const
let {
userDataDir,
profileDirName
} = await ChromiumExtensionRunner.getProfilePaths(this.params.chromiumProfile);
if (userDataDir && this.params.keepProfileChanges) {
if (profileDirName && !(await ChromiumExtensionRunner.isUserDataDir(userDataDir))) {
throw new Error('The profile you provided is not in a ' + 'user-data-dir. The changes cannot be kept. Please either ' + 'remove --keep-profile-changes or use a profile in a ' + 'user-data-dir directory');
}
} else if (!this.params.keepProfileChanges) {
// the user provided an existing profile directory but doesn't want
// the changes to be kept. we copy this directory to a temporary
// user data dir.
const tmpDir = new TempDir();
await tmpDir.create();
const tmpDirPath = tmpDir.path();
if (userDataDir && profileDirName) {
// copy profile dir to this temp user data dir.
await fs.cp(path.join(userDataDir, profileDirName), path.join(tmpDirPath, profileDirName), {
recursive: true
});
} else if (userDataDir) {
await fs.cp(userDataDir, tmpDirPath, {
recursive: true
});
}
userDataDir = tmpDirPath;
}
if (profileDirName) {
chromeFlags.push(`--profile-directory=${profileDirName}`);
}
let startingUrl;
if (this.params.startUrl) {
const startingUrls = Array.isArray(this.params.startUrl) ? this.params.startUrl : [this.params.startUrl];
startingUrl = startingUrls.shift();
chromeFlags.push(...startingUrls);
}
this.chromiumInstance = await this.chromiumLaunch({
chromePath: chromiumBinary,
chromeFlags,
startingUrl,
userDataDir,
logLevel: this.params.verbose ? 'verbose' : 'silent',
// Ignore default flags to keep the extension enabled.
ignoreDefaultFlags: true
});
this.cdp = new ChromeDevtoolsProtocolClient(this.chromiumInstance);
const initialChromiumInstance = this.chromiumInstance;
this.chromiumInstance.process.once('close', () => {
if (this.chromiumInstance !== initialChromiumInstance) {
// This happens when we restart Chrome to fall back to --load-extension.
return;
}
this.chromiumInstance = null;
if (!this.exiting) {
log.info('Exiting on Chromium instance disconnected.');
this.exit();
}
});
if (!this.forceUseDeprecatedLoadExtension) {
// Assume that the required Extensions.loadUnpacked CDP method is
// supported. If it is not, we will fall back to --load-extension.
let cdpSupportsExtensionsLoadUnpacked = true;
for (const sourceDir of extensions) {
try {
await this.cdp.sendCommand('Extensions.loadUnpacked', {
path: sourceDir
});
} catch (e) {
// Chrome 125- will emit the following message:
if (e.message === "'Extensions.loadUnpacked' wasn't found") {
cdpSupportsExtensionsLoadUnpacked = false;
break;
}
log.error(`Failed to load extension at ${sourceDir}: ${e.message}`);
// We do not have to throw - the extension can work again when
// auto-reload is used. But users may like a hard fail, and this is
// consistent with the firefox runner.
throw e;
}
}
if (!cdpSupportsExtensionsLoadUnpacked) {
// Retry once, now with --load-extension.
log.warn('Cannot load extension via CDP, falling back to old method');
this.forceUseDeprecatedLoadExtension = true;
this.chromiumInstance = null;
await initialChromiumInstance.kill();
await this.cdp.waitUntilDisconnected();
this.cdp = null;
return this.setupInstance();
}
}
}
/**
* Reloads all the extensions, collect any reload error and resolves to
* an array composed by a single ExtensionRunnerReloadResult object.
*/
async reloadAllExtensions() {
const runnerName = this.getName();
if (this.forceUseDeprecatedLoadExtension) {
this.reloadAllExtensionsFallbackForChrome125andEarlier();
} else {
for (const {
sourceDir
} of this.params.extensions) {
try {
await this.cdp.sendCommand('Extensions.loadUnpacked', {
path: sourceDir
});
} catch (e) {
log.error(`Failed to load extension at ${sourceDir}: ${e.message}`);
}
}
}
process.stdout.write(`\rLast extension reload: ${new Date().toTimeString()}`);
log.debug('\n');
return [{
runnerName
}];
}
async reloadAllExtensionsFallbackForChrome125andEarlier() {
// Ideally, we'd like to use the "Extensions.loadUnpacked" CDP command to
// reload an extension, but that is unsupported in Chrome 125 and earlier.
//
// As a fallback, connect to chrome://extensions/ and reload from there.
// Since we are targeting old Chrome versions, we can safely use the
// chrome.developerPrivate APIs, because these are never going to change
// for the old browser versions. Do NOT use this for newer versions!
//
// Target.* CDP methods documented at: https://chromedevtools.github.io/devtools-protocol/tot/Target/
// developerPrivate documented at:
// https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl
//
// Specific revision that exposed developerPrivate to chrome://extensions/:
// https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl;drc=69bf75316e7ae533c0a0dccc1a56ca019aa95a1e
// https://chromium.googlesource.com/chromium/src.git/+/69bf75316e7ae533c0a0dccc1a56ca019aa95a1e
//
// Specific revision that introduced developerPrivate.getExtensionsInfo:
// https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl;drc=69bf75316e7ae533c0a0dccc1a56ca019aa95a1e
//
// The above changes are from 2015; The --remote-debugging-pipe feature
// that we rely on for CDP was added in 2018; this is the version of the
// developerPrivate API at that time:
// https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl;drc=c9ae59c8f37d487f1f01c222deb6b7d1f51c99c2
// Find an existing chrome://extensions/ tab, if it exists.
let {
targetInfos: targets
} = await this.cdp.sendCommand('Target.getTargets', {
filter: [{
type: 'tab'
}]
});
targets = targets.filter(t => t.url.startsWith('chrome://extensions/'));
let targetId;
const hasExistingTarget = targets.length > 0;
if (hasExistingTarget) {
targetId = targets[0].targetId;
} else {
const result = await this.cdp.sendCommand('Target.createTarget', {
url: 'chrome://extensions/',
newWindow: true,
background: true,
windowState: 'minimized'
});
targetId = result.targetId;
}
const codeToEvaluateInChrome = async () => {
// This function is serialized and executed in Chrome. Designed for
// compatibility with Chrome 69 - 125. Do not use JS syntax of functions
// that are not supported in these versions!
// eslint-disable-next-line no-undef
const developerPrivate = chrome.developerPrivate;
if (!developerPrivate || !developerPrivate.getExtensionsInfo) {
// When chrome://extensions/ is still loading, its document URL may be
// about:blank and the chrome.developerPrivate API is not exposed.
return 'NOT_READY_PLEASE_RETRY';
}
const extensionIds = [];
await new Promise(resolve => {
developerPrivate.getExtensionsInfo(extensions => {
for (const extension of extensions || []) {
if (extension.location === 'UNPACKED') {
// We only care about those loaded via --load-extension.
extensionIds.push(extension.id);
}
}
resolve();
});
});
const reloadPromises = extensionIds.map(extensionId => {
return new Promise((resolve, reject) => {
developerPrivate.reload(extensionId,
// Suppress alert dialog when load fails.
{
failQuietly: true,
populateErrorForUnpacked: true
}, loadError => {
if (loadError) {
reject(new Error(loadError.error));
} else {
resolve();
}
});
});
});
await Promise.all(reloadPromises);
return reloadPromises.length;
};
try {
const targetResult = await this.cdp.sendCommand('Target.attachToTarget', {
targetId,
flatten: true
});
if (!targetResult.sessionId) {
throw new Error('Unexpectedly, no sessionId from attachToTarget');
}
// In practice, we're going to run the logic only once. But if we are
// unlucky, chrome://extensions is still loading, so we will then retry.
for (let i = 0; i < 3; ++i) {
var _evalResult$result;
const evalResult = await this.cdp.sendCommand('Runtime.evaluate', {
expression: `(${codeToEvaluateInChrome})();`,
awaitPromise: true
}, targetResult.sessionId);
const evalResultReturnValue = (_evalResult$result = evalResult.result) === null || _evalResult$result === void 0 ? void 0 : _evalResult$result.value;
if (evalResultReturnValue === 'NOT_READY_PLEASE_RETRY') {
await new Promise(r => setTimeout(r, 200 * i));
continue;
}
if (evalResult.exceptionDetails) {
log.error(`Failed to reload: ${evalResult.exceptionDetails.text}`);
}
if (evalResultReturnValue !== this.params.extensions.length) {
log.warn(`Failed to reload extensions: ${evalResultReturnValue}`);
}
break;
}
} finally {
if (!hasExistingTarget && targetId) {
try {
await this.cdp.sendCommand('Target.closeTarget', {
targetId
});
} catch (e) {
log.error(e);
}
}
}
}
/**
* Reloads a single extension, collect any reload error and resolves to
* an array composed by a single ExtensionRunnerReloadResult object.
*/
async reloadExtensionBySourceDir(extensionSourceDir // eslint-disable-line no-unused-vars
) {
// TODO(rpl): detect the extension ids assigned to the
// target extensions and map it to the extensions source dir
// (https://github.com/mozilla/web-ext/issues/1687).
return this.reloadAllExtensions();
}
/**
* Register a callback to be called when the runner has been exited
* (e.g. the Chromium instance exits or the user has requested web-ext
* to exit).
*/
registerCleanup(fn) {
this.cleanupCallbacks.add(fn);
}
/**
* Exits the runner, by closing the managed Chromium instance.
*/
async exit() {
this.exiting = true;
// Wait for the setup to complete if the extension runner is already
// being started.
if (this._promiseSetupDone) {
// Ignore initialization errors if any.
await this._promiseSetupDone.catch(err => {
log.debug(`ignored setup error on chromium runner shutdown: ${err}`);
});
}
if (this.chromiumInstance) {
await this.chromiumInstance.kill();
this.chromiumInstance = null;
}
if (this.cdp) {
await this.cdp.waitUntilDisconnected();
this.cdp = null;
}
// Call all the registered cleanup callbacks.
for (const fn of this.cleanupCallbacks) {
try {
fn();
} catch (error) {
log.error(error);
}
}
}
}
//# sourceMappingURL=chromium.js.map