Source code

Revision control

Copy as Markdown

Other Tools

/* -*- 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/. */
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { XPCShellContentUtils } from "resource://testing-common/XPCShellContentUtils.sys.mjs";
/** @type {Lazy} */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
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 {
/** @type {import("resource://gre/modules/addons/XPIDatabase.sys.mjs").AddonWrapper} */
addon;
/** @type {Promise} */
addonPromise;
/** @type {nsIFile[]} */
cleanupFiles;
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();
}
/**
* Override for subclasses which don't set an ID in the constructor.
*
* @param {nsIURI} _uri
* @param {string} _id
*/
maybeSetID(_uri, _id) {}
}
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);
}
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 ExternallyInstalledWrapper extends AOMExtensionWrapper {
constructor(testScope, id) {
super(testScope);
this.id = id;
this.startupPromise = new Promise(resolve => {
this.resolveStartup = resolve;
});
this.state = "restarting";
}
}
export 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.ensureInitialized(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);
});
}
},
/** @param {[origin: string, url: string, options: object]} args */
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.
*
* @returns {XPCShellContentUtils.ContentPage}
*/
loadContentPage(url, options) {
return XPCShellContentUtils.loadContentPage(url, options);
},
};