Files
tubestation/toolkit/components/extensions/ExtensionXPCShellUtils.jsm
Noemi Erli a08e78ef6e Backed out 4 changesets (bug 1648545) for causing mass failures
Backed out changeset ac3d639547fa (bug 1648545)
Backed out changeset f63760c845f3 (bug 1648545)
Backed out changeset 652b48b10d37 (bug 1648545)
Backed out changeset 1ca7abd873d1 (bug 1648545)
2023-05-26 02:12:58 +03:00

804 lines
20 KiB
JavaScript

/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
var EXPORTED_SYMBOLS = ["ExtensionTestUtils"];
const { AppConstants } = ChromeUtils.importESModule(
"resource://gre/modules/AppConstants.sys.mjs"
);
const { XPCShellContentUtils } = ChromeUtils.importESModule(
"resource://testing-common/XPCShellContentUtils.sys.mjs"
);
const lazy = {};
ChromeUtils.defineModuleGetter(
lazy,
"AddonManager",
"resource://gre/modules/AddonManager.jsm"
);
ChromeUtils.defineModuleGetter(
lazy,
"AddonTestUtils",
"resource://testing-common/AddonTestUtils.jsm"
);
ChromeUtils.defineModuleGetter(
lazy,
"ExtensionTestCommon",
"resource://testing-common/ExtensionTestCommon.jsm"
);
ChromeUtils.defineESModuleGetters(lazy, {
FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
});
ChromeUtils.defineModuleGetter(
lazy,
"Management",
"resource://gre/modules/Extension.jsm"
);
ChromeUtils.defineModuleGetter(
lazy,
"Schemas",
"resource://gre/modules/Schemas.jsm"
);
let BASE_MANIFEST = Object.freeze({
browser_specific_settings: Object.freeze({
gecko: Object.freeze({
id: "test@web.ext",
}),
}),
manifest_version: 2,
name: "name",
version: "0",
});
class ExtensionWrapper {
constructor(testScope, extension = null) {
this.testScope = testScope;
this.extension = null;
this.handleResult = this.handleResult.bind(this);
this.handleMessage = this.handleMessage.bind(this);
this.state = "uninitialized";
this.testResolve = null;
this.testDone = new Promise(resolve => {
this.testResolve = resolve;
});
this.messageHandler = new Map();
this.messageAwaiter = new Map();
this.messageQueue = new Set();
this.testScope.registerCleanupFunction(() => {
this.clearMessageQueues();
if (this.state == "pending" || this.state == "running") {
this.testScope.equal(
this.state,
"unloaded",
"Extension left running at test shutdown"
);
return this.unload();
} else if (this.state == "unloading") {
this.testScope.equal(
this.state,
"unloaded",
"Extension not fully unloaded at test shutdown"
);
}
this.destroy();
});
if (extension) {
this.id = extension.id;
this.attachExtension(extension);
}
}
destroy() {
// This method should be implemented in subclasses which need to
// perform cleanup when destroyed.
}
attachExtension(extension) {
if (extension === this.extension) {
return;
}
if (this.extension) {
this.extension.off("test-eq", this.handleResult);
this.extension.off("test-log", this.handleResult);
this.extension.off("test-result", this.handleResult);
this.extension.off("test-done", this.handleResult);
this.extension.off("test-message", this.handleMessage);
this.clearMessageQueues();
}
this.uuid = extension.uuid;
this.extension = extension;
extension.on("test-eq", this.handleResult);
extension.on("test-log", this.handleResult);
extension.on("test-result", this.handleResult);
extension.on("test-done", this.handleResult);
extension.on("test-message", this.handleMessage);
this.testScope.info(`Extension attached`);
}
clearMessageQueues() {
if (this.messageQueue.size) {
let names = Array.from(this.messageQueue, ([msg]) => msg);
this.testScope.equal(
JSON.stringify(names),
"[]",
"message queue is empty"
);
this.messageQueue.clear();
}
if (this.messageAwaiter.size) {
let names = Array.from(this.messageAwaiter.keys());
this.testScope.equal(
JSON.stringify(names),
"[]",
"no tasks awaiting on messages"
);
for (let promise of this.messageAwaiter.values()) {
promise.reject();
}
this.messageAwaiter.clear();
}
}
handleResult(kind, pass, msg, expected, actual) {
switch (kind) {
case "test-eq":
this.testScope.ok(
pass,
`${msg} - Expected: ${expected}, Actual: ${actual}`
);
break;
case "test-log":
this.testScope.info(msg);
break;
case "test-result":
this.testScope.ok(pass, msg);
break;
case "test-done":
this.testScope.ok(pass, msg);
this.testResolve(msg);
break;
}
}
handleMessage(kind, msg, ...args) {
let handler = this.messageHandler.get(msg);
if (handler) {
handler(...args);
} else {
this.messageQueue.add([msg, ...args]);
this.checkMessages();
}
}
awaitStartup() {
return this.startupPromise;
}
awaitBackgroundStarted() {
if (!this.extension.manifest.background) {
throw new Error("Extension has no background");
}
return Promise.all([
this.startupPromise,
this.extension.promiseBackgroundStarted(),
]);
}
async startup() {
if (this.state != "uninitialized") {
throw new Error("Extension already started");
}
this.state = "pending";
await lazy.ExtensionTestCommon.setIncognitoOverride(this.extension);
this.startupPromise = this.extension.startup().then(
result => {
this.state = "running";
return result;
},
error => {
this.state = "failed";
return Promise.reject(error);
}
);
return this.startupPromise;
}
async unload() {
if (this.state != "running") {
throw new Error("Extension not running");
}
this.state = "unloading";
if (this.addonPromise) {
// If addonPromise is still pending resolution, wait for it to make sure
// that add-ons that are installed through the AddonManager are properly
// uninstalled.
await this.addonPromise;
}
if (this.addon) {
await this.addon.uninstall();
} else {
await this.extension.shutdown();
}
if (AppConstants.platform === "android") {
// We need a way to notify the embedding layer that an extension has been
// uninstalled, so that the java layer can be updated too.
Services.obs.notifyObservers(
null,
"testing-uninstalled-addon",
this.addon ? this.addon.id : this.extension.id
);
}
this.state = "unloaded";
}
/**
* This method sends the message to force-sleep the background scripts.
*
* @returns {Promise} resolves after the background is asleep and listeners primed.
*/
terminateBackground(...args) {
return this.extension.terminateBackground(...args);
}
wakeupBackground() {
return this.extension.wakeupBackground();
}
sendMessage(...args) {
this.extension.testMessage(...args);
}
awaitFinish(msg) {
return this.testDone.then(actual => {
if (msg) {
this.testScope.equal(actual, msg, "test result correct");
}
return actual;
});
}
checkMessages() {
for (let message of this.messageQueue) {
let [msg, ...args] = message;
let listener = this.messageAwaiter.get(msg);
if (listener) {
this.messageQueue.delete(message);
this.messageAwaiter.delete(msg);
listener.resolve(...args);
return;
}
}
}
checkDuplicateListeners(msg) {
if (this.messageHandler.has(msg) || this.messageAwaiter.has(msg)) {
throw new Error("only one message handler allowed");
}
}
awaitMessage(msg) {
return new Promise((resolve, reject) => {
this.checkDuplicateListeners(msg);
this.messageAwaiter.set(msg, { resolve, reject });
this.checkMessages();
});
}
onMessage(msg, callback) {
this.checkDuplicateListeners(msg);
this.messageHandler.set(msg, callback);
}
}
class AOMExtensionWrapper extends ExtensionWrapper {
constructor(testScope) {
super(testScope);
this.onEvent = this.onEvent.bind(this);
lazy.Management.on("ready", this.onEvent);
lazy.Management.on("shutdown", this.onEvent);
lazy.Management.on("startup", this.onEvent);
lazy.AddonTestUtils.on("addon-manager-shutdown", this.onEvent);
lazy.AddonTestUtils.on("addon-manager-started", this.onEvent);
lazy.AddonManager.addAddonListener(this);
}
destroy() {
this.id = null;
this.addon = null;
lazy.Management.off("ready", this.onEvent);
lazy.Management.off("shutdown", this.onEvent);
lazy.Management.off("startup", this.onEvent);
lazy.AddonTestUtils.off("addon-manager-shutdown", this.onEvent);
lazy.AddonTestUtils.off("addon-manager-started", this.onEvent);
lazy.AddonManager.removeAddonListener(this);
}
setRestarting() {
if (this.state !== "restarting") {
this.startupPromise = new Promise(resolve => {
this.resolveStartup = resolve;
}).then(async result => {
await this.addonPromise;
return result;
});
}
this.state = "restarting";
}
onEnabling(addon) {
if (addon.id === this.id) {
this.setRestarting();
}
}
onInstalling(addon) {
if (addon.id === this.id) {
this.setRestarting();
}
}
onInstalled(addon) {
if (addon.id === this.id) {
this.addon = addon;
}
}
onUninstalled(addon) {
if (addon.id === this.id) {
this.destroy();
}
}
onEvent(kind, ...args) {
switch (kind) {
case "addon-manager-started":
if (this.state === "uninitialized") {
// startup() not called yet, ignore AddonManager startup notification.
return;
}
this.addonPromise = lazy.AddonManager.getAddonByID(this.id).then(
addon => {
this.addon = addon;
this.addonPromise = null;
}
);
// FALLTHROUGH
case "addon-manager-shutdown":
if (this.state === "uninitialized") {
return;
}
this.addon = null;
this.setRestarting();
break;
case "startup": {
let [extension] = args;
this.maybeSetID(extension.rootURI, extension.id);
if (extension.id === this.id) {
this.attachExtension(extension);
this.state = "pending";
}
break;
}
case "shutdown": {
let [extension] = args;
if (extension.id === this.id && this.state !== "restarting") {
this.state = "unloaded";
}
break;
}
case "ready": {
let [extension] = args;
if (extension.id === this.id) {
this.state = "running";
if (AppConstants.platform === "android") {
// We need a way to notify the embedding layer that a new extension
// has been installed, so that the java layer can be updated too.
Services.obs.notifyObservers(
null,
"testing-installed-addon",
extension.id
);
}
this.resolveStartup(extension);
}
break;
}
}
}
async _flushCache() {
if (this.extension && this.extension.rootURI instanceof Ci.nsIJARURI) {
let file = this.extension.rootURI.JARFile.QueryInterface(
Ci.nsIFileURL
).file;
await Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", {
path: file.path,
});
}
}
get version() {
return this.addon && this.addon.version;
}
async unload() {
await this._flushCache();
return super.unload();
}
async upgrade(data) {
this.startupPromise = new Promise(resolve => {
this.resolveStartup = resolve;
});
this.state = "restarting";
await this._flushCache();
let xpiFile = lazy.ExtensionTestCommon.generateXPI(data);
this.cleanupFiles.push(xpiFile);
return this._install(xpiFile);
}
}
class InstallableWrapper extends AOMExtensionWrapper {
constructor(testScope, xpiFile, addonData = {}) {
super(testScope);
this.file = xpiFile;
this.addonData = addonData;
this.installType = addonData.useAddonManager || "temporary";
this.installTelemetryInfo = addonData.amInstallTelemetryInfo;
this.cleanupFiles = [xpiFile];
}
destroy() {
super.destroy();
for (let file of this.cleanupFiles.splice(0)) {
try {
Services.obs.notifyObservers(file, "flush-cache-entry");
file.remove(false);
} catch (e) {
Cu.reportError(e);
}
}
}
maybeSetID(uri, id) {
if (
!this.id &&
uri instanceof Ci.nsIJARURI &&
uri.JARFile.QueryInterface(Ci.nsIFileURL).file.equals(this.file)
) {
this.id = id;
}
}
_setIncognitoOverride() {
// this.id is not set yet so grab it from the manifest data to set
// the incognito permission.
let { addonData } = this;
if (addonData && addonData.incognitoOverride) {
try {
let { id } = addonData.manifest.browser_specific_settings.gecko;
if (id) {
return lazy.ExtensionTestCommon.setIncognitoOverride({
id,
addonData,
});
}
} catch (e) {}
throw new Error(
"Extension ID is required for setting incognito permission."
);
}
}
async _install(xpiFile) {
await this._setIncognitoOverride();
if (this.installType === "temporary") {
return lazy.AddonManager.installTemporaryAddon(xpiFile)
.then(addon => {
this.id = addon.id;
this.addon = addon;
return this.startupPromise;
})
.catch(e => {
this.state = "unloaded";
return Promise.reject(e);
});
} else if (this.installType === "permanent") {
return lazy.AddonManager.getInstallForFile(
xpiFile,
null,
this.installTelemetryInfo
).then(install => {
let listener = {
onDownloadFailed: () => {
this.state = "unloaded";
this.resolveStartup(Promise.reject(new Error("Install failed")));
},
onInstallFailed: () => {
this.state = "unloaded";
this.resolveStartup(Promise.reject(new Error("Install failed")));
},
onInstallEnded: (install, newAddon) => {
this.id = newAddon.id;
this.addon = newAddon;
},
};
install.addListener(listener);
install.install();
return this.startupPromise;
});
}
}
startup() {
if (this.state != "uninitialized") {
throw new Error("Extension already started");
}
this.state = "pending";
this.startupPromise = new Promise(resolve => {
this.resolveStartup = resolve;
});
return this._install(this.file);
}
}
class ExternallyInstalledWrapper extends AOMExtensionWrapper {
constructor(testScope, id) {
super(testScope);
this.id = id;
this.startupPromise = new Promise(resolve => {
this.resolveStartup = resolve;
});
this.state = "restarting";
}
maybeSetID(uri, id) {}
}
var ExtensionTestUtils = {
BASE_MANIFEST,
get testAssertions() {
return lazy.ExtensionTestCommon.testAssertions;
},
// Shortcut to more easily access WebExtensionPolicy.backgroundServiceWorkerEnabled
// from mochitest-plain tests.
getBackgroundServiceWorkerEnabled() {
return lazy.ExtensionTestCommon.getBackgroundServiceWorkerEnabled();
},
// A test helper used to check if the pref "extension.backgroundServiceWorker.forceInTestExtension"
// is set to true.
isInBackgroundServiceWorkerTests() {
return lazy.ExtensionTestCommon.isInBackgroundServiceWorkerTests();
},
async normalizeManifest(
manifest,
manifestType = "manifest.WebExtensionManifest",
baseManifest = BASE_MANIFEST
) {
await lazy.Management.lazyInit();
manifest = Object.assign({}, baseManifest, manifest);
let errors = [];
let context = {
url: null,
manifestVersion: manifest.manifest_version,
logError: error => {
errors.push(error);
},
preprocessors: {},
};
let normalized = lazy.Schemas.normalize(manifest, manifestType, context);
normalized.errors = errors;
return normalized;
},
currentScope: null,
profileDir: null,
init(scope) {
XPCShellContentUtils.init(scope);
this.currentScope = scope;
this.profileDir = scope.do_get_profile();
let tmpD = this.profileDir.clone();
tmpD.append("tmp");
tmpD.create(Ci.nsIFile.DIRECTORY_TYPE, lazy.FileUtils.PERMS_DIRECTORY);
let dirProvider = {
getFile(prop, persistent) {
persistent.value = false;
if (prop == "TmpD") {
return tmpD.clone();
}
return null;
},
QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]),
};
Services.dirsvc.registerProvider(dirProvider);
scope.registerCleanupFunction(() => {
try {
tmpD.remove(true);
} catch (e) {
Cu.reportError(e);
}
Services.dirsvc.unregisterProvider(dirProvider);
this.currentScope = null;
});
},
addonManagerStarted: false,
mockAppInfo() {
lazy.AddonTestUtils.createAppInfo(
"xpcshell@tests.mozilla.org",
"XPCShell",
"48",
"48"
);
},
startAddonManager() {
if (this.addonManagerStarted) {
return;
}
this.addonManagerStarted = true;
this.mockAppInfo();
return lazy.AddonTestUtils.promiseStartupManager();
},
loadExtension(data) {
if (data.useAddonManager) {
// If we're using incognitoOverride, we'll need to ensure
// an ID is available before generating the XPI.
if (data.incognitoOverride) {
lazy.ExtensionTestCommon.setExtensionID(data);
}
let xpiFile = lazy.ExtensionTestCommon.generateXPI(data);
return this.loadExtensionXPI(xpiFile, data);
}
let extension = lazy.ExtensionTestCommon.generate(data);
return new ExtensionWrapper(this.currentScope, extension);
},
loadExtensionXPI(xpiFile, data) {
return new InstallableWrapper(this.currentScope, xpiFile, data);
},
// Create a wrapper for a webextension that will be installed
// by some external process (e.g., Normandy)
expectExtension(id) {
return new ExternallyInstalledWrapper(this.currentScope, id);
},
failOnSchemaWarnings(warningsAsErrors = true) {
let prefName = "extensions.webextensions.warnings-as-errors";
Services.prefs.setBoolPref(prefName, warningsAsErrors);
if (!warningsAsErrors) {
this.currentScope.registerCleanupFunction(() => {
Services.prefs.setBoolPref(prefName, true);
});
}
},
get remoteContentScripts() {
return XPCShellContentUtils.remoteContentScripts;
},
set remoteContentScripts(val) {
XPCShellContentUtils.remoteContentScripts = val;
},
async fetch(...args) {
return XPCShellContentUtils.fetch(...args);
},
/**
* Loads a content page into a hidden docShell.
*
* @param {string} url
* The URL to load.
* @param {object} [options = {}]
* @param {ExtensionWrapper} [options.extension]
* If passed, load the URL as an extension page for the given
* extension.
* @param {boolean} [options.remote]
* If true, load the URL in a content process. If false, load
* it in the parent process.
* @param {boolean} [options.remoteSubframes]
* If true, load cross-origin frames in separate content processes.
* This is ignored if |options.remote| is false.
* @param {string} [options.redirectUrl]
* An optional URL that the initial page is expected to
* redirect to.
* @param {...any} args
* Extra parameters to ensure compatibility
*
* @returns {ContentPage}
*/
loadContentPage(url, options, ...args) {
return XPCShellContentUtils.loadContentPage(url, options, ...args);
},
};