gecko-dev/toolkit/components/extensions/ExtensionTestCommon.jsm
Kris Maglione fcedebb912 Bug 1471102: Move more code out of ExtensionUtils.jsm. r=aswan
MozReview-Commit-ID: Fqlv5BRuuW8

--HG--
extra : rebase_source : 348f037abd9cecfa080183bc365e5f005eac1bd6
extra : amend_source : 05dbfd12f553fc3f2a93374402e34d271e26d767
2018-06-25 19:30:21 -07:00

425 lines
13 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";
/**
* This module contains extension testing helper logic which is common
* between all test suites.
*/
/* exported ExtensionTestCommon, MockExtension */
var EXPORTED_SYMBOLS = ["ExtensionTestCommon", "MockExtension"];
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyGlobalGetters(this, ["TextEncoder"]);
ChromeUtils.defineModuleGetter(this, "AddonManager",
"resource://gre/modules/AddonManager.jsm");
ChromeUtils.defineModuleGetter(this, "Extension",
"resource://gre/modules/Extension.jsm");
ChromeUtils.defineModuleGetter(this, "ExtensionParent",
"resource://gre/modules/ExtensionParent.jsm");
ChromeUtils.defineModuleGetter(this, "FileUtils",
"resource://gre/modules/FileUtils.jsm");
ChromeUtils.defineModuleGetter(this, "OS",
"resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyGetter(this, "apiManager",
() => ExtensionParent.apiManager);
ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm");
ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
XPCOMUtils.defineLazyServiceGetter(this, "uuidGen",
"@mozilla.org/uuid-generator;1",
"nsIUUIDGenerator");
const {
flushJarCache,
} = ExtensionUtils;
const {
instanceOf,
} = ExtensionCommon;
XPCOMUtils.defineLazyGetter(this, "console", () => ExtensionCommon.getConsole());
/**
* A skeleton Extension-like object, used for testing, which installs an
* add-on via the add-on manager when startup() is called, and
* uninstalles it on shutdown().
*
* @param {string} id
* @param {nsIFile} file
* @param {nsIURI} rootURI
* @param {string} installType
* @param {boolean} [embedded = false]
*/
class MockExtension {
constructor(file, rootURI, installType, embedded) {
this.id = null;
this.file = file;
this.rootURI = rootURI;
this.installType = installType;
this.addon = null;
let promiseEvent = eventName => new Promise(resolve => {
let onstartup = async (msg, extension) => {
this.maybeSetID(extension.rootURI, extension.id);
if (!this.id && this.addonPromise) {
await this.addonPromise;
}
if (extension.id == this.id) {
apiManager.off(eventName, onstartup);
this._extension = extension;
resolve(extension);
}
};
apiManager.on(eventName, onstartup);
});
this._extension = null;
this._extensionPromise = promiseEvent("startup");
this._readyPromise = promiseEvent("ready");
if (!embedded) {
this._uninstallPromise = promiseEvent("uninstall-complete");
}
}
maybeSetID(uri, id) {
if (!this.id && uri instanceof Ci.nsIJARURI &&
uri.JARFile.QueryInterface(Ci.nsIFileURL)
.file.equals(this.file)) {
this.id = id;
}
}
testMessage(...args) {
return this._extension.testMessage(...args);
}
on(...args) {
this._extensionPromise.then(extension => {
extension.on(...args);
});
}
off(...args) {
this._extensionPromise.then(extension => {
extension.off(...args);
});
}
startup() {
if (this.installType == "temporary") {
return AddonManager.installTemporaryAddon(this.file).then(addon => {
this.addon = addon;
return this._readyPromise;
});
} else if (this.installType == "permanent") {
this.addonPromise = new Promise(resolve => {
this.resolveAddon = resolve;
});
return new Promise(async (resolve, reject) => {
let install = await AddonManager.getInstallForFile(this.file);
let listener = {
onInstallFailed: reject,
onInstallEnded: (install, newAddon) => {
this.addon = newAddon;
this.id = newAddon.id;
this.resolveAddon(newAddon);
resolve(this._readyPromise);
},
};
install.addListener(listener);
install.install();
});
}
throw new Error("installType must be one of: temporary, permanent");
}
shutdown() {
this.addon.uninstall();
return this.cleanupGeneratedFile();
}
cleanupGeneratedFile() {
return this._extensionPromise.then(extension => {
return extension.broadcast("Extension:FlushJarCache", {path: this.file.path});
}).then(() => {
return OS.File.remove(this.file.path);
});
}
}
function provide(obj, keys, value, override = false) {
if (keys.length == 1) {
if (!(keys[0] in obj) || override) {
obj[keys[0]] = value;
}
} else {
if (!(keys[0] in obj)) {
obj[keys[0]] = {};
}
provide(obj[keys[0]], keys.slice(1), value, override);
}
}
var ExtensionTestCommon = class ExtensionTestCommon {
static generateManifest(manifest) {
provide(manifest, ["name"], "Generated extension");
provide(manifest, ["manifest_version"], 2);
provide(manifest, ["version"], "1.0");
return manifest;
}
/**
* This code is designed to make it easy to test a WebExtension
* without creating a bunch of files. Everything is contained in a
* single JSON blob.
*
* Properties:
* "background": "<JS code>"
* A script to be loaded as the background script.
* The "background" section of the "manifest" property is overwritten
* if this is provided.
* "manifest": {...}
* Contents of manifest.json
* "files": {"filename1": "contents1", ...}
* Data to be included as files. Can be referenced from the manifest.
* If a manifest file is provided here, it takes precedence over
* a generated one. Always use "/" as a directory separator.
* Directories should appear here only implicitly (as a prefix
* to file names)
*
* To make things easier, the value of "background" and "files"[] can
* be a function, which is converted to source that is run.
*
* The generated extension is stored in the system temporary directory,
* and an nsIFile object pointing to it is returned.
*
* @param {object} data
* @returns {nsIFile}
*/
static generateXPI(data) {
let manifest = data.manifest;
if (!manifest) {
manifest = {};
}
let files = Object.assign({}, data.files);
provide(manifest, ["name"], "Generated extension");
provide(manifest, ["manifest_version"], 2);
provide(manifest, ["version"], "1.0");
if (data.background) {
let bgScript = uuidGen.generateUUID().number + ".js";
provide(manifest, ["background", "scripts"], [bgScript], true);
files[bgScript] = data.background;
}
provide(files, ["manifest.json"], manifest);
if (data.embedded) {
// Package this as a webextension embedded inside a legacy
// extension.
let xpiFiles = {
"install.rdf": `<?xml version="1.0" encoding="UTF-8"?>
<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:em="http://www.mozilla.org/2004/em-rdf#">
<Description about="urn:mozilla:install-manifest"
em:id="${manifest.applications.gecko.id}"
em:name="${manifest.name}"
em:type="2"
em:version="${manifest.version}"
em:description=""
em:multiprocessCompatible="true"
em:hasEmbeddedWebExtension="true"
em:bootstrap="true">
<!-- Firefox -->
<em:targetApplication>
<Description
em:id="{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"
em:minVersion="51.0a1"
em:maxVersion="*"/>
</em:targetApplication>
<em:targetApplication>
<Description>
<em:id>toolkit@mozilla.org</em:id>
<em:minVersion>0</em:minVersion>
<em:maxVersion>*</em:maxVersion>
</Description>
</em:targetApplication>
</Description>
</RDF>
`,
"bootstrap.js": `
function install() {}
function uninstall() {}
function shutdown() {}
function startup(data) {
data.webExtension.startup();
}
`,
};
for (let [path, data] of Object.entries(files)) {
xpiFiles[`webextension/${path}`] = data;
}
files = xpiFiles;
}
return this.generateZipFile(files);
}
static generateZipFile(files, baseName = "generated-extension.xpi") {
let ZipWriter = Components.Constructor("@mozilla.org/zipwriter;1", "nsIZipWriter");
let zipW = new ZipWriter();
let file = FileUtils.getFile("TmpD", [baseName]);
file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
const MODE_WRONLY = 0x02;
const MODE_TRUNCATE = 0x20;
zipW.open(file, MODE_WRONLY | MODE_TRUNCATE);
// Needs to be in microseconds for some reason.
let time = Date.now() * 1000;
function generateFile(filename) {
let components = filename.split("/");
let path = "";
for (let component of components.slice(0, -1)) {
path += component + "/";
if (!zipW.hasEntry(path)) {
zipW.addEntryDirectory(path, time, false);
}
}
}
for (let filename in files) {
let script = files[filename];
if (typeof(script) == "function") {
script = this.serializeScript(script);
} else if (instanceOf(script, "Object") || instanceOf(script, "Array")) {
script = JSON.stringify(script);
}
if (!instanceOf(script, "ArrayBuffer")) {
script = new TextEncoder("utf-8").encode(script).buffer;
}
let stream = Cc["@mozilla.org/io/arraybuffer-input-stream;1"].createInstance(Ci.nsIArrayBufferInputStream);
stream.setData(script, 0, script.byteLength);
generateFile(filename);
zipW.addEntryStream(filename, time, 0, stream, false);
}
zipW.close();
return file;
}
/**
* Properly serialize a function into eval-able code string.
*
* @param {function} script
* @returns {string}
*/
static serializeFunction(script) {
// Serialization of object methods doesn't include `function` anymore.
const method = /^(async )?(?:(\w+)|"(\w+)\.js")\(/;
let code = script.toString();
let match = code.match(method);
if (match && match[2] !== "function") {
code = code.replace(method, "$1function $2$3(");
}
return code;
}
/**
* Properly serialize a script into eval-able code string.
*
* @param {string|function|Array} script
* @returns {string}
*/
static serializeScript(script) {
if (Array.isArray(script)) {
return Array.from(script, this.serializeScript, this).join(";");
}
if (typeof script !== "function") {
return script;
}
return `(${this.serializeFunction(script)})();`;
}
/**
* Generates a new extension using |Extension.generateXPI|, and initializes a
* new |Extension| instance which will execute it.
*
* @param {object} data
* @returns {Extension}
*/
static generate(data) {
let file = this.generateXPI(data);
flushJarCache(file.path);
Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", {path: file.path});
let fileURI = Services.io.newFileURI(file);
let jarURI = Services.io.newURI("jar:" + fileURI.spec + "!/");
// This may be "temporary" or "permanent".
if (data.useAddonManager) {
return new MockExtension(file, jarURI, data.useAddonManager, data.embedded);
}
let id;
if (data.manifest) {
if (data.manifest.applications && data.manifest.applications.gecko) {
id = data.manifest.applications.gecko.id;
} else if (data.manifest.browser_specific_settings && data.manifest.browser_specific_settings.gecko) {
id = data.manifest.browser_specific_settings.gecko.id;
}
}
if (!id) {
id = uuidGen.generateUUID().number;
}
let signedState = AddonManager.SIGNEDSTATE_SIGNED;
if (data.isPrivileged) {
signedState = AddonManager.SIGNEDSTATE_PRIVILEGED;
}
if (data.isSystem) {
signedState = AddonManager.SIGNEDSTATE_SYSTEM;
}
return new Extension({
id,
resourceURI: jarURI,
cleanupFile: file,
signedState,
temporarilyInstalled: !!data.temporarilyInstalled,
TEST_NO_ADDON_MANAGER: true,
});
}
};