mirror of
https://github.com/ManchildProductions/UXP-Fixed.git
synced 2026-06-20 01:28:28 +00:00
1232 lines
39 KiB
JavaScript
1232 lines
39 KiB
JavaScript
/* Any copyright is dedicated to the Public Domain.
|
|
* http://creativecommons.org/publicdomain/zero/1.0/
|
|
*/
|
|
|
|
/* eslint "mozilla/no-aArgs": 1 */
|
|
/* eslint "no-unused-vars": [2, {"args": "none", "varsIgnorePattern": "^(Cc|Ci|Cr|Cu|EXPORTED_SYMBOLS)$"}] */
|
|
/* eslint "semi": [2, "always"] */
|
|
/* eslint "valid-jsdoc": [2, {requireReturn: false}] */
|
|
|
|
var EXPORTED_SYMBOLS = ["AddonTestUtils", "MockAsyncShutdown"];
|
|
|
|
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
|
|
|
|
const CERTDB_CONTRACTID = "@mozilla.org/security/x509certdb;1";
|
|
const CERTDB_CID = Components.ID("{fb0bbc5c-452e-4783-b32c-80124693d871}");
|
|
|
|
|
|
Cu.importGlobalProperties(["fetch", "TextEncoder"]);
|
|
|
|
Cu.import("resource://gre/modules/AsyncShutdown.jsm");
|
|
Cu.import("resource://gre/modules/FileUtils.jsm");
|
|
Cu.import("resource://gre/modules/NetUtil.jsm");
|
|
Cu.import("resource://gre/modules/Services.jsm");
|
|
Cu.import("resource://gre/modules/Task.jsm");
|
|
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
|
|
|
const {EventEmitter} = Cu.import("resource://devtools/shared/event-emitter.js", {});
|
|
const {OS} = Cu.import("resource://gre/modules/osfile.jsm", {});
|
|
|
|
XPCOMUtils.defineLazyModuleGetter(this, "Extension",
|
|
"resource://gre/modules/Extension.jsm");
|
|
|
|
XPCOMUtils.defineLazyServiceGetter(this, "rdfService",
|
|
"@mozilla.org/rdf/rdf-service;1", "nsIRDFService");
|
|
XPCOMUtils.defineLazyServiceGetter(this, "uuidGen",
|
|
"@mozilla.org/uuid-generator;1", "nsIUUIDGenerator");
|
|
|
|
|
|
XPCOMUtils.defineLazyGetter(this, "AppInfo", () => {
|
|
let AppInfo = {};
|
|
Cu.import("resource://testing-common/AppInfo.jsm", AppInfo);
|
|
return AppInfo;
|
|
});
|
|
|
|
|
|
const ArrayBufferInputStream = Components.Constructor(
|
|
"@mozilla.org/io/arraybuffer-input-stream;1",
|
|
"nsIArrayBufferInputStream", "setData");
|
|
|
|
const nsFile = Components.Constructor(
|
|
"@mozilla.org/file/local;1",
|
|
"nsIFile", "initWithPath");
|
|
|
|
const RDFXMLParser = Components.Constructor(
|
|
"@mozilla.org/rdf/xml-parser;1",
|
|
"nsIRDFXMLParser", "parseString");
|
|
|
|
const RDFDataSource = Components.Constructor(
|
|
"@mozilla.org/rdf/datasource;1?name=in-memory-datasource",
|
|
"nsIRDFDataSource");
|
|
|
|
const ZipReader = Components.Constructor(
|
|
"@mozilla.org/libjar/zip-reader;1",
|
|
"nsIZipReader", "open");
|
|
|
|
const ZipWriter = Components.Constructor(
|
|
"@mozilla.org/zipwriter;1",
|
|
"nsIZipWriter", "open");
|
|
|
|
|
|
// We need some internal bits of AddonManager
|
|
var AMscope = Cu.import("resource://gre/modules/AddonManager.jsm", {});
|
|
var {AddonManager, AddonManagerPrivate} = AMscope;
|
|
|
|
|
|
// Mock out AddonManager's reference to the AsyncShutdown module so we can shut
|
|
// down AddonManager from the test
|
|
var MockAsyncShutdown = {
|
|
hook: null,
|
|
status: null,
|
|
profileBeforeChange: {
|
|
addBlocker: function(name, blocker, options) {
|
|
MockAsyncShutdown.hook = blocker;
|
|
MockAsyncShutdown.status = options.fetchState;
|
|
}
|
|
},
|
|
// We can use the real Barrier
|
|
Barrier: AsyncShutdown.Barrier,
|
|
};
|
|
|
|
AMscope.AsyncShutdown = MockAsyncShutdown;
|
|
|
|
|
|
/**
|
|
* Escapes any occurances of &, ", < or > with XML entities.
|
|
*
|
|
* @param {string} str
|
|
* The string to escape.
|
|
* @return {string} The escaped string.
|
|
*/
|
|
function escapeXML(str) {
|
|
let replacements = {"&": "&", '"': """, "'": "'", "<": "<", ">": ">"};
|
|
return String(str).replace(/[&"''<>]/g, m => replacements[m]);
|
|
}
|
|
|
|
/**
|
|
* A tagged template function which escapes any XML metacharacters in
|
|
* interpolated values.
|
|
*
|
|
* @param {Array<string>} strings
|
|
* An array of literal strings extracted from the templates.
|
|
* @param {Array} values
|
|
* An array of interpolated values extracted from the template.
|
|
* @returns {string}
|
|
* The result of the escaped values interpolated with the literal
|
|
* strings.
|
|
*/
|
|
function escaped(strings, ...values) {
|
|
let result = [];
|
|
|
|
for (let [i, string] of strings.entries()) {
|
|
result.push(string);
|
|
if (i < values.length)
|
|
result.push(escapeXML(values[i]));
|
|
}
|
|
|
|
return result.join("");
|
|
}
|
|
|
|
|
|
class AddonsList {
|
|
constructor(extensionsINI) {
|
|
this.multiprocessIncompatibleIDs = new Set();
|
|
|
|
if (!extensionsINI.exists()) {
|
|
this.extensions = [];
|
|
this.themes = [];
|
|
return;
|
|
}
|
|
|
|
let factory = Cc["@mozilla.org/xpcom/ini-parser-factory;1"]
|
|
.getService(Ci.nsIINIParserFactory);
|
|
|
|
let parser = factory.createINIParser(extensionsINI);
|
|
|
|
function readDirectories(section) {
|
|
var dirs = [];
|
|
var keys = parser.getKeys(section);
|
|
for (let key of XPCOMUtils.IterStringEnumerator(keys)) {
|
|
let descriptor = parser.getString(section, key);
|
|
|
|
let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
|
|
try {
|
|
file.persistentDescriptor = descriptor;
|
|
} catch (e) {
|
|
// Throws if the directory doesn't exist, we can ignore this since the
|
|
// platform will too.
|
|
continue;
|
|
}
|
|
dirs.push(file);
|
|
}
|
|
return dirs;
|
|
}
|
|
|
|
this.extensions = readDirectories("ExtensionDirs");
|
|
this.themes = readDirectories("ThemeDirs");
|
|
|
|
var keys = parser.getKeys("MultiprocessIncompatibleExtensions");
|
|
for (let key of XPCOMUtils.IterStringEnumerator(keys)) {
|
|
let id = parser.getString("MultiprocessIncompatibleExtensions", key);
|
|
this.multiprocessIncompatibleIDs.add(id);
|
|
}
|
|
}
|
|
|
|
hasItem(type, dir, id) {
|
|
var path = dir.clone();
|
|
path.append(id);
|
|
|
|
var xpiPath = dir.clone();
|
|
xpiPath.append(`${id}.xpi`);
|
|
|
|
return this[type].some(file => {
|
|
if (!file.exists())
|
|
throw new Error(`Non-existent path found in extensions.ini: ${file.path}`);
|
|
|
|
if (file.isDirectory())
|
|
return file.equals(path);
|
|
if (file.isFile())
|
|
return file.equals(xpiPath);
|
|
return false;
|
|
});
|
|
}
|
|
|
|
isMultiprocessIncompatible(id) {
|
|
return this.multiprocessIncompatibleIDs.has(id);
|
|
}
|
|
|
|
hasTheme(dir, id) {
|
|
return this.hasItem("themes", dir, id);
|
|
}
|
|
|
|
hasExtension(dir, id) {
|
|
return this.hasItem("extensions", dir, id);
|
|
}
|
|
}
|
|
|
|
var AddonTestUtils = {
|
|
addonIntegrationService: null,
|
|
addonsList: null,
|
|
appInfo: null,
|
|
extensionsINI: null,
|
|
testUnpacked: false,
|
|
useRealCertChecks: false,
|
|
|
|
init(testScope) {
|
|
this.testScope = testScope;
|
|
|
|
// Get the profile directory for tests to use.
|
|
this.profileDir = testScope.do_get_profile();
|
|
|
|
this.extensionsINI = this.profileDir.clone();
|
|
this.extensionsINI.append("extensions.ini");
|
|
|
|
// Register a temporary directory for the tests.
|
|
this.tempDir = this.profileDir.clone();
|
|
this.tempDir.append("temp");
|
|
this.tempDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
|
|
this.registerDirectory("TmpD", this.tempDir);
|
|
|
|
// Create a replacement app directory for the tests.
|
|
const appDirForAddons = this.profileDir.clone();
|
|
appDirForAddons.append("appdir-addons");
|
|
appDirForAddons.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
|
|
this.registerDirectory("XREAddonAppDir", appDirForAddons);
|
|
|
|
|
|
// Enable more extensive EM logging
|
|
Services.prefs.setBoolPref("extensions.logging.enabled", true);
|
|
|
|
// By default only load extensions from the profile install location
|
|
Services.prefs.setIntPref("extensions.enabledScopes", AddonManager.SCOPE_PROFILE);
|
|
|
|
// By default don't disable add-ons from any scope
|
|
Services.prefs.setIntPref("extensions.autoDisableScopes", 0);
|
|
|
|
// By default, don't cache add-ons in AddonRepository.jsm
|
|
Services.prefs.setBoolPref("extensions.getAddons.cache.enabled", false);
|
|
|
|
// Disable the compatibility updates window by default
|
|
Services.prefs.setBoolPref("extensions.showMismatchUI", false);
|
|
|
|
// Point update checks to the local machine for fast failures
|
|
Services.prefs.setCharPref("extensions.update.url", "http://127.0.0.1/updateURL");
|
|
Services.prefs.setCharPref("extensions.update.background.url", "http://127.0.0.1/updateBackgroundURL");
|
|
Services.prefs.setCharPref("extensions.blocklist.url", "http://127.0.0.1/blocklistURL");
|
|
|
|
// By default ignore bundled add-ons
|
|
Services.prefs.setBoolPref("extensions.installDistroAddons", false);
|
|
|
|
// By default don't check for hotfixes
|
|
Services.prefs.setCharPref("extensions.hotfix.id", "");
|
|
|
|
// Ensure signature checks are enabled by default
|
|
Services.prefs.setBoolPref("xpinstall.signatures.required", true);
|
|
|
|
|
|
// Write out an empty blocklist.xml file to the profile to ensure nothing
|
|
// is blocklisted by default
|
|
var blockFile = OS.Path.join(this.profileDir.path, "blocklist.xml");
|
|
|
|
var data = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
|
"<blocklist xmlns=\"http://www.mozilla.org/2006/addons-blocklist\">\n" +
|
|
"</blocklist>\n";
|
|
|
|
this.awaitPromise(OS.File.writeAtomic(blockFile, new TextEncoder().encode(data)));
|
|
|
|
|
|
// Make sure that a given path does not exist
|
|
function pathShouldntExist(file) {
|
|
if (file.exists()) {
|
|
throw new Error(`Test cleanup: path ${file.path} exists when it should not`);
|
|
}
|
|
}
|
|
|
|
testScope.do_register_cleanup(() => {
|
|
for (let file of this.tempXPIs) {
|
|
if (file.exists())
|
|
file.remove(false);
|
|
}
|
|
|
|
// Check that the temporary directory is empty
|
|
var dirEntries = this.tempDir.directoryEntries
|
|
.QueryInterface(Ci.nsIDirectoryEnumerator);
|
|
var entries = [];
|
|
while (dirEntries.hasMoreElements())
|
|
entries.push(dirEntries.nextFile.leafName);
|
|
if (entries.length)
|
|
throw new Error(`Found unexpected files in temporary directory: ${entries.join(", ")}`);
|
|
|
|
dirEntries.close();
|
|
|
|
try {
|
|
appDirForAddons.remove(true);
|
|
} catch (ex) {
|
|
testScope.do_print(`Got exception removing addon app dir: ${ex}`);
|
|
}
|
|
|
|
// ensure no leftover files in the system addon upgrade location
|
|
let featuresDir = this.profileDir.clone();
|
|
featuresDir.append("features");
|
|
// upgrade directories will be in UUID folders under features/
|
|
let systemAddonDirs = [];
|
|
if (featuresDir.exists()) {
|
|
let featuresDirEntries = featuresDir.directoryEntries
|
|
.QueryInterface(Ci.nsIDirectoryEnumerator);
|
|
while (featuresDirEntries.hasMoreElements()) {
|
|
let entry = featuresDirEntries.getNext();
|
|
entry.QueryInterface(Components.interfaces.nsIFile);
|
|
systemAddonDirs.push(entry);
|
|
}
|
|
|
|
systemAddonDirs.map(dir => {
|
|
dir.append("stage");
|
|
pathShouldntExist(dir);
|
|
});
|
|
}
|
|
|
|
// ensure no leftover files in the user addon location
|
|
let testDir = this.profileDir.clone();
|
|
testDir.append("extensions");
|
|
testDir.append("trash");
|
|
pathShouldntExist(testDir);
|
|
|
|
testDir.leafName = "staged";
|
|
pathShouldntExist(testDir);
|
|
|
|
return this.promiseShutdownManager();
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Helper to spin the event loop until a promise resolves or rejects
|
|
*
|
|
* @param {Promise} promise
|
|
* The promise to wait on.
|
|
* @returns {*} The promise's resolution value.
|
|
* @throws The promise's rejection value, if it rejects.
|
|
*/
|
|
awaitPromise(promise) {
|
|
let done = false;
|
|
let result;
|
|
let error;
|
|
promise.then(
|
|
val => { result = val; },
|
|
err => { error = err; }
|
|
).then(() => {
|
|
done = true;
|
|
});
|
|
|
|
while (!done)
|
|
Services.tm.mainThread.processNextEvent(true);
|
|
|
|
if (error !== undefined)
|
|
throw error;
|
|
return result;
|
|
},
|
|
|
|
createAppInfo(ID, name, version, platformVersion = "1.0") {
|
|
AppInfo.updateAppInfo({
|
|
ID, name, version, platformVersion,
|
|
crashReporter: true,
|
|
extraProps: {
|
|
browserTabsRemoteAutostart: false,
|
|
},
|
|
});
|
|
this.appInfo = AppInfo.getAppInfo();
|
|
},
|
|
|
|
getManifestURI(file) {
|
|
if (file.isDirectory()) {
|
|
file.append("install.rdf");
|
|
if (file.exists()) {
|
|
return NetUtil.newURI(file);
|
|
}
|
|
|
|
file.leafName = "manifest.json";
|
|
if (file.exists())
|
|
return NetUtil.newURI(file);
|
|
|
|
throw new Error("No manifest file present");
|
|
}
|
|
|
|
let zip = ZipReader(file);
|
|
try {
|
|
let uri = NetUtil.newURI(file);
|
|
|
|
if (zip.hasEntry("install.rdf")) {
|
|
return NetUtil.newURI(`jar:${uri.spec}!/install.rdf`);
|
|
}
|
|
|
|
if (zip.hasEntry("manifest.json")) {
|
|
return NetUtil.newURI(`jar:${uri.spec}!/manifest.json`);
|
|
}
|
|
|
|
throw new Error("No manifest file present");
|
|
} finally {
|
|
zip.close();
|
|
}
|
|
},
|
|
|
|
getIDFromManifest: Task.async(function*(manifestURI) {
|
|
let body = yield fetch(manifestURI.spec);
|
|
|
|
if (manifestURI.spec.endsWith(".rdf")) {
|
|
let data = yield body.text();
|
|
|
|
let ds = new RDFDataSource();
|
|
new RDFXMLParser(ds, manifestURI, data);
|
|
|
|
let rdfID = ds.GetTarget(rdfService.GetResource("urn:mozilla:install-manifest"),
|
|
rdfService.GetResource("http://www.mozilla.org/2004/em-rdf#id"),
|
|
true);
|
|
return rdfID.QueryInterface(Ci.nsIRDFLiteral).Value;
|
|
}
|
|
|
|
let manifest = yield body.json();
|
|
try {
|
|
return manifest.applications.gecko.id;
|
|
} catch (e) {
|
|
// IDs for WebExtensions are extracted from the certificate when
|
|
// not present in the manifest, so just generate a random one.
|
|
return uuidGen.generateUUID().number;
|
|
}
|
|
}),
|
|
|
|
overrideCertDB() {
|
|
// Unregister the real database. This only works because the add-ons manager
|
|
// hasn't started up and grabbed the certificate database yet.
|
|
let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
|
|
let factory = registrar.getClassObject(CERTDB_CID, Ci.nsIFactory);
|
|
registrar.unregisterFactory(CERTDB_CID, factory);
|
|
|
|
// Get the real DB
|
|
let realCertDB = factory.createInstance(null, Ci.nsIX509CertDB);
|
|
|
|
|
|
let verifyCert = Task.async(function*(file, result, cert, callback) {
|
|
if (result == Cr.NS_ERROR_SIGNED_JAR_NOT_SIGNED &&
|
|
!this.useRealCertChecks && callback.wrappedJSObject) {
|
|
// Bypassing XPConnect allows us to create a fake x509 certificate from JS
|
|
callback = callback.wrappedJSObject;
|
|
|
|
try {
|
|
let manifestURI = this.getManifestURI(file);
|
|
|
|
let id = yield this.getIDFromManifest(manifestURI);
|
|
|
|
let fakeCert = {commonName: id};
|
|
|
|
return [callback, Cr.NS_OK, fakeCert];
|
|
} catch (e) {
|
|
// If there is any error then just pass along the original results
|
|
} finally {
|
|
// Make sure to close the open zip file or it will be locked.
|
|
if (file.isFile())
|
|
Services.obs.notifyObservers(file, "flush-cache-entry", "cert-override");
|
|
}
|
|
}
|
|
|
|
return [callback, result, cert];
|
|
}).bind(this);
|
|
|
|
|
|
function FakeCertDB() {
|
|
for (let property of Object.keys(realCertDB)) {
|
|
if (property in this)
|
|
continue;
|
|
|
|
if (typeof realCertDB[property] == "function")
|
|
this[property] = realCertDB[property].bind(realCertDB);
|
|
}
|
|
}
|
|
FakeCertDB.prototype = {
|
|
openSignedAppFileAsync(root, file, callback) {
|
|
// First try calling the real cert DB
|
|
realCertDB.openSignedAppFileAsync(root, file, (result, zipReader, cert) => {
|
|
verifyCert(file.clone(), result, cert, callback)
|
|
.then(([callback, result, cert]) => {
|
|
callback.openSignedAppFileFinished(result, zipReader, cert);
|
|
});
|
|
});
|
|
},
|
|
|
|
verifySignedDirectoryAsync(root, dir, callback) {
|
|
// First try calling the real cert DB
|
|
realCertDB.verifySignedDirectoryAsync(root, dir, (result, cert) => {
|
|
verifyCert(dir.clone(), result, cert, callback)
|
|
.then(([callback, result, cert]) => {
|
|
callback.verifySignedDirectoryFinished(result, cert);
|
|
});
|
|
});
|
|
},
|
|
|
|
QueryInterface: XPCOMUtils.generateQI([Ci.nsIX509CertDB]),
|
|
};
|
|
|
|
let certDBFactory = XPCOMUtils.generateSingletonFactory(FakeCertDB);
|
|
registrar.registerFactory(CERTDB_CID, "CertDB",
|
|
CERTDB_CONTRACTID, certDBFactory);
|
|
},
|
|
|
|
/**
|
|
* Starts up the add-on manager as if it was started by the application.
|
|
*
|
|
* @param {boolean} [appChanged = true]
|
|
* An optional boolean parameter to simulate the case where the
|
|
* application has changed version since the last run. If not passed it
|
|
* defaults to true
|
|
* @returns {Promise}
|
|
* Resolves when the add-on manager's startup has completed.
|
|
*/
|
|
promiseStartupManager(appChanged = true) {
|
|
if (this.addonIntegrationService)
|
|
throw new Error("Attempting to startup manager that was already started.");
|
|
|
|
if (appChanged && this.extensionsINI.exists())
|
|
this.extensionsINI.remove(true);
|
|
|
|
this.addonIntegrationService = Cc["@mozilla.org/addons/integration;1"]
|
|
.getService(Ci.nsIObserver);
|
|
|
|
this.addonIntegrationService.observe(null, "addons-startup", null);
|
|
|
|
this.emit("addon-manager-started");
|
|
|
|
// Load the add-ons list as it was after extension registration
|
|
this.loadAddonsList();
|
|
|
|
return Promise.resolve();
|
|
},
|
|
|
|
promiseShutdownManager() {
|
|
if (!this.addonIntegrationService)
|
|
return Promise.resolve(false);
|
|
|
|
Services.obs.notifyObservers(null, "quit-application-granted", null);
|
|
return MockAsyncShutdown.hook()
|
|
.then(() => {
|
|
this.emit("addon-manager-shutdown");
|
|
|
|
this.addonIntegrationService = null;
|
|
|
|
// Load the add-ons list as it was after application shutdown
|
|
this.loadAddonsList();
|
|
|
|
// Clear any crash report annotations
|
|
this.appInfo.annotations = {};
|
|
|
|
// Force the XPIProvider provider to reload to better
|
|
// simulate real-world usage.
|
|
let XPIscope = Cu.import("resource://gre/modules/addons/XPIProvider.jsm");
|
|
// This would be cleaner if I could get it as the rejection reason from
|
|
// the AddonManagerInternal.shutdown() promise
|
|
let shutdownError = XPIscope.XPIProvider._shutdownError;
|
|
|
|
AddonManagerPrivate.unregisterProvider(XPIscope.XPIProvider);
|
|
Cu.unload("resource://gre/modules/addons/XPIProvider.jsm");
|
|
|
|
if (shutdownError)
|
|
throw shutdownError;
|
|
|
|
return true;
|
|
});
|
|
},
|
|
|
|
promiseRestartManager(newVersion) {
|
|
return this.promiseShutdownManager()
|
|
.then(() => {
|
|
if (newVersion)
|
|
this.appInfo.version = newVersion;
|
|
|
|
return this.promiseStartupManager(!!newVersion);
|
|
});
|
|
},
|
|
|
|
loadAddonsList() {
|
|
this.addonsList = new AddonsList(this.extensionsINI);
|
|
},
|
|
|
|
/**
|
|
* Creates an update.rdf structure as a string using for the update data passed.
|
|
*
|
|
* @param {Object} data
|
|
* The update data as a JS object. Each property name is an add-on ID,
|
|
* the property value is an array of each version of the add-on. Each
|
|
* array value is a JS object containing the data for the version, at
|
|
* minimum a "version" and "targetApplications" property should be
|
|
* included to create a functional update manifest.
|
|
* @return {string} The update.rdf structure as a string.
|
|
*/
|
|
createUpdateRDF(data) {
|
|
var rdf = '<?xml version="1.0"?>\n';
|
|
rdf += '<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"\n' +
|
|
' xmlns:em="http://www.mozilla.org/2004/em-rdf#">\n';
|
|
|
|
for (let addon in data) {
|
|
rdf += escaped` <Description about="urn:mozilla:extension:${addon}"><em:updates><Seq>\n`;
|
|
|
|
for (let versionData of data[addon]) {
|
|
rdf += ' <li><Description>\n';
|
|
rdf += this._writeProps(versionData, ["version", "multiprocessCompatible"],
|
|
` `);
|
|
for (let app of versionData.targetApplications || []) {
|
|
rdf += " <em:targetApplication><Description>\n";
|
|
rdf += this._writeProps(app, ["id", "minVersion", "maxVersion", "updateLink", "updateHash"],
|
|
` `);
|
|
rdf += " </Description></em:targetApplication>\n";
|
|
}
|
|
rdf += ' </Description></li>\n';
|
|
}
|
|
rdf += ' </Seq></em:updates></Description>\n';
|
|
}
|
|
rdf += "</RDF>\n";
|
|
|
|
return rdf;
|
|
},
|
|
|
|
_writeProps(obj, props, indent = " ") {
|
|
let items = [];
|
|
for (let prop of props) {
|
|
if (prop in obj)
|
|
items.push(escaped`${indent}<em:${prop}>${obj[prop]}</em:${prop}>\n`);
|
|
}
|
|
return items.join("");
|
|
},
|
|
|
|
_writeArrayProps(obj, props, indent = " ") {
|
|
let items = [];
|
|
for (let prop of props) {
|
|
for (let val of obj[prop] || [])
|
|
items.push(escaped`${indent}<em:${prop}>${val}</em:${prop}>\n`);
|
|
}
|
|
return items.join("");
|
|
},
|
|
|
|
_writeLocaleStrings(data) {
|
|
let items = [];
|
|
|
|
items.push(this._writeProps(data, ["name", "description", "creator", "homepageURL"]));
|
|
items.push(this._writeArrayProps(data, ["developer", "translator", "contributor"]));
|
|
|
|
return items.join("");
|
|
},
|
|
|
|
createInstallRDF(data) {
|
|
var rdf = '<?xml version="1.0"?>\n';
|
|
rdf += '<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"\n' +
|
|
' xmlns:em="http://www.mozilla.org/2004/em-rdf#">\n';
|
|
|
|
rdf += '<Description about="urn:mozilla:install-manifest">\n';
|
|
|
|
let props = ["id", "version", "type", "internalName", "updateURL", "updateKey",
|
|
"optionsURL", "optionsType", "aboutURL", "iconURL", "icon64URL",
|
|
"skinnable", "bootstrap", "unpack", "strictCompatibility",
|
|
"multiprocessCompatible", "hasEmbeddedWebExtension"];
|
|
rdf += this._writeProps(data, props);
|
|
|
|
rdf += this._writeLocaleStrings(data);
|
|
|
|
for (let platform of data.targetPlatforms || [])
|
|
rdf += escaped`<em:targetPlatform>${platform}</em:targetPlatform>\n`;
|
|
|
|
for (let app of data.targetApplications || []) {
|
|
rdf += "<em:targetApplication><Description>\n";
|
|
rdf += this._writeProps(app, ["id", "minVersion", "maxVersion"]);
|
|
rdf += "</Description></em:targetApplication>\n";
|
|
}
|
|
|
|
for (let localized of data.localized || []) {
|
|
rdf += "<em:localized><Description>\n";
|
|
rdf += this._writeArrayProps(localized, ["locale"]);
|
|
rdf += this._writeLocaleStrings(localized);
|
|
rdf += "</Description></em:localized>\n";
|
|
}
|
|
|
|
for (let dep of data.dependencies || [])
|
|
rdf += escaped`<em:dependency><Description em:id="${dep}"/></em:dependency>\n`;
|
|
|
|
rdf += "</Description>\n</RDF>\n";
|
|
return rdf;
|
|
},
|
|
|
|
/**
|
|
* Recursively create all directories upto and including the given
|
|
* path, if they do not exist.
|
|
*
|
|
* @param {string} path The path of the directory to create.
|
|
* @returns {Promise} Resolves when all directories have been created.
|
|
*/
|
|
recursiveMakeDir(path) {
|
|
let paths = [];
|
|
for (let lastPath; path != lastPath; lastPath = path, path = OS.Path.dirname(path))
|
|
paths.push(path);
|
|
|
|
return Promise.all(paths.reverse().map(path =>
|
|
OS.File.makeDir(path, {ignoreExisting: true}).catch(() => {})));
|
|
},
|
|
|
|
/**
|
|
* Writes the given data to a file in the given zip file.
|
|
*
|
|
* @param {string|nsIFile} zipFile
|
|
* The zip file to write to.
|
|
* @param {Object} files
|
|
* An object containing filenames and the data to write to the
|
|
* corresponding paths in the zip file.
|
|
* @param {integer} [flags = 0]
|
|
* Additional flags to open the file with.
|
|
*/
|
|
writeFilesToZip(zipFile, files, flags = 0) {
|
|
if (typeof zipFile == "string")
|
|
zipFile = nsFile(zipFile);
|
|
|
|
var zipW = ZipWriter(zipFile, FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE | flags);
|
|
|
|
for (let [path, data] of Object.entries(files)) {
|
|
if (!(data instanceof ArrayBuffer))
|
|
data = new TextEncoder("utf-8").encode(data).buffer;
|
|
|
|
let stream = ArrayBufferInputStream(data, 0, data.byteLength);
|
|
|
|
// Note these files are being created in the XPI archive with date "0" which is 1970-01-01.
|
|
zipW.addEntryStream(path, 0, Ci.nsIZipWriter.COMPRESSION_NONE,
|
|
stream, false);
|
|
}
|
|
|
|
zipW.close();
|
|
},
|
|
|
|
promiseWriteFilesToZip: Task.async(function*(zip, files, flags) {
|
|
yield this.recursiveMakeDir(OS.Path.dirname(zip));
|
|
|
|
this.writeFilesToZip(zip, files, flags);
|
|
|
|
return Promise.resolve(nsFile(zip));
|
|
}),
|
|
|
|
promiseWriteFilesToDir: Task.async(function*(dir, files) {
|
|
yield this.recursiveMakeDir(dir);
|
|
|
|
for (let [path, data] of Object.entries(files)) {
|
|
path = path.split("/");
|
|
let leafName = path.pop();
|
|
|
|
// Create parent directories, if necessary.
|
|
let dirPath = dir;
|
|
for (let subDir of path) {
|
|
dirPath = OS.Path.join(dirPath, subDir);
|
|
yield OS.Path.makeDir(dirPath, {ignoreExisting: true});
|
|
}
|
|
|
|
if (typeof data == "string")
|
|
data = new TextEncoder("utf-8").encode(data);
|
|
|
|
yield OS.File.writeAtomic(OS.Path.join(dirPath, leafName), data);
|
|
}
|
|
|
|
return nsFile(dir);
|
|
}),
|
|
|
|
promiseWriteFilesToExtension(dir, id, files, unpacked = this.testUnpacked) {
|
|
if (typeof files["install.rdf"] === "object")
|
|
files["install.rdf"] = this.createInstallRDF(files["install.rdf"]);
|
|
|
|
if (unpacked) {
|
|
let path = OS.Path.join(dir, id);
|
|
|
|
return this.promiseWriteFilesToDir(path, files);
|
|
}
|
|
|
|
let xpi = OS.Path.join(dir, `${id}.xpi`);
|
|
|
|
return this.promiseWriteFilesToZip(xpi, files);
|
|
},
|
|
|
|
tempXPIs: [],
|
|
/**
|
|
* Creates an XPI file for some manifest data in the temporary directory and
|
|
* returns the nsIFile for it. The file will be deleted when the test completes.
|
|
*
|
|
* @param {object} files
|
|
* The object holding data about the add-on
|
|
* @return {nsIFile} A file pointing to the created XPI file
|
|
*/
|
|
createTempXPIFile(files) {
|
|
var file = this.tempDir.clone();
|
|
let uuid = uuidGen.generateUUID().number.slice(1, -1);
|
|
file.append(`${uuid}.xpi`);
|
|
|
|
this.tempXPIs.push(file);
|
|
|
|
if (typeof files["install.rdf"] === "object")
|
|
files["install.rdf"] = this.createInstallRDF(files["install.rdf"]);
|
|
|
|
this.writeFilesToZip(file.path, files);
|
|
return file;
|
|
},
|
|
|
|
/**
|
|
* Creates an XPI file for some WebExtension data in the temporary directory and
|
|
* returns the nsIFile for it. The file will be deleted when the test completes.
|
|
*
|
|
* @param {Object} data
|
|
* The object holding data about the add-on, as expected by
|
|
* |Extension.generateXPI|.
|
|
* @return {nsIFile} A file pointing to the created XPI file
|
|
*/
|
|
createTempWebExtensionFile(data) {
|
|
let file = Extension.generateXPI(data);
|
|
this.tempXPIs.push(file);
|
|
return file;
|
|
},
|
|
|
|
/**
|
|
* Creates an extension proxy file.
|
|
* See: https://developer.mozilla.org/en-US/Add-ons/Setting_up_extension_development_environment#Firefox_extension_proxy_file
|
|
*
|
|
* @param {nsIFile} dir
|
|
* The directory to add the proxy file to.
|
|
* @param {nsIFile} addon
|
|
* An nsIFile for the add-on file that this is a proxy file for.
|
|
* @param {string} id
|
|
* A string to use for the add-on ID.
|
|
* @returns {Promise} Resolves when the file has been created.
|
|
*/
|
|
promiseWriteProxyFileToDir(dir, addon, id) {
|
|
let files = {
|
|
[id]: addon.path,
|
|
};
|
|
|
|
return this.promiseWriteFilesToDir(dir.path, files);
|
|
},
|
|
|
|
/**
|
|
* Manually installs an XPI file into an install location by either copying the
|
|
* XPI there or extracting it depending on whether unpacking is being tested
|
|
* or not.
|
|
*
|
|
* @param {nsIFile} xpiFile
|
|
* The XPI file to install.
|
|
* @param {nsIFile} installLocation
|
|
* The install location (an nsIFile) to install into.
|
|
* @param {string} id
|
|
* The ID to install as.
|
|
* @param {boolean} [unpacked = this.testUnpacked]
|
|
* If true, install as an unpacked directory, rather than a
|
|
* packed XPI.
|
|
* @returns {nsIFile}
|
|
* A file pointing to the installed location of the XPI file or
|
|
* unpacked directory.
|
|
*/
|
|
manuallyInstall(xpiFile, installLocation, id, unpacked = this.testUnpacked) {
|
|
if (unpacked) {
|
|
let dir = installLocation.clone();
|
|
dir.append(id);
|
|
dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
|
|
|
|
let zip = ZipReader(xpiFile);
|
|
let entries = zip.findEntries(null);
|
|
while (entries.hasMore()) {
|
|
let entry = entries.getNext();
|
|
let target = dir.clone();
|
|
for (let part of entry.split("/"))
|
|
target.append(part);
|
|
zip.extract(entry, target);
|
|
}
|
|
zip.close();
|
|
|
|
return dir;
|
|
}
|
|
|
|
let target = installLocation.clone();
|
|
target.append(`${id}.xpi`);
|
|
xpiFile.copyTo(target.parent, target.leafName);
|
|
return target;
|
|
},
|
|
|
|
/**
|
|
* Manually uninstalls an add-on by removing its files from the install
|
|
* location.
|
|
*
|
|
* @param {nsIFile} installLocation
|
|
* The nsIFile of the install location to remove from.
|
|
* @param {string} id
|
|
* The ID of the add-on to remove.
|
|
* @param {boolean} [unpacked = this.testUnpacked]
|
|
* If true, uninstall an unpacked directory, rather than a
|
|
* packed XPI.
|
|
*/
|
|
manuallyUninstall(installLocation, id, unpacked = this.testUnpacked) {
|
|
let file = this.getFileForAddon(installLocation, id, unpacked);
|
|
|
|
// In reality because the app is restarted a flush isn't necessary for XPIs
|
|
// removed outside the app, but for testing we must flush manually.
|
|
if (file.isFile())
|
|
Services.obs.notifyObservers(file, "flush-cache-entry", null);
|
|
|
|
file.remove(true);
|
|
},
|
|
|
|
/**
|
|
* Gets the nsIFile for where an add-on is installed. It may point to a file or
|
|
* a directory depending on whether add-ons are being installed unpacked or not.
|
|
*
|
|
* @param {nsIFile} dir
|
|
* The nsIFile for the install location
|
|
* @param {string} id
|
|
* The ID of the add-on
|
|
* @param {boolean} [unpacked = this.testUnpacked]
|
|
* If true, return the path to an unpacked directory, rather than a
|
|
* packed XPI.
|
|
* @returns {nsIFile}
|
|
* A file pointing to the XPI file or unpacked directory where
|
|
* the add-on should be installed.
|
|
*/
|
|
getFileForAddon(dir, id, unpacked = this.testUnpacked) {
|
|
dir = dir.clone();
|
|
if (unpacked)
|
|
dir.append(id);
|
|
else
|
|
dir.append(`${id}.xpi`);
|
|
return dir;
|
|
},
|
|
|
|
/**
|
|
* Sets the last modified time of the extension, usually to trigger an update
|
|
* of its metadata. If the extension is unpacked, this function assumes that
|
|
* the extension contains only the install.rdf file.
|
|
*
|
|
* @param {nsIFile} ext A file pointing to either the packed extension or its unpacked directory.
|
|
* @param {number} time The time to which we set the lastModifiedTime of the extension
|
|
*
|
|
* @deprecated Please use promiseSetExtensionModifiedTime instead
|
|
*/
|
|
setExtensionModifiedTime(ext, time) {
|
|
ext.lastModifiedTime = time;
|
|
if (ext.isDirectory()) {
|
|
let entries = ext.directoryEntries
|
|
.QueryInterface(Ci.nsIDirectoryEnumerator);
|
|
while (entries.hasMoreElements())
|
|
this.setExtensionModifiedTime(entries.nextFile, time);
|
|
entries.close();
|
|
}
|
|
},
|
|
|
|
promiseSetExtensionModifiedTime: Task.async(function*(path, time) {
|
|
yield OS.File.setDates(path, time, time);
|
|
|
|
let iterator = new OS.File.DirectoryIterator(path);
|
|
try {
|
|
yield iterator.forEach(entry => {
|
|
return this.promiseSetExtensionModifiedTime(entry.path, time);
|
|
});
|
|
} catch (ex) {
|
|
if (ex instanceof OS.File.Error)
|
|
return;
|
|
throw ex;
|
|
} finally {
|
|
iterator.close().catch(() => {});
|
|
}
|
|
}),
|
|
|
|
registerDirectory(key, dir) {
|
|
var dirProvider = {
|
|
getFile(prop, persistent) {
|
|
persistent.value = false;
|
|
if (prop == key)
|
|
return dir.clone();
|
|
return null;
|
|
},
|
|
|
|
QueryInterface: XPCOMUtils.generateQI([Ci.nsIDirectoryServiceProvider]),
|
|
};
|
|
Services.dirsvc.registerProvider(dirProvider);
|
|
},
|
|
|
|
/**
|
|
* Returns a promise that resolves when the given add-on event is fired. The
|
|
* resolved value is an array of arguments passed for the event.
|
|
*
|
|
* @param {string} event
|
|
* The name of the AddonListener event handler method for which
|
|
* an event is expected.
|
|
* @returns {Promise<Array>}
|
|
* Resolves to an array containing the event handler's
|
|
* arguments the first time it is called.
|
|
*/
|
|
promiseAddonEvent(event) {
|
|
return new Promise(resolve => {
|
|
let listener = {
|
|
[event](...args) {
|
|
AddonManager.removeAddonListener(listener);
|
|
resolve(args);
|
|
},
|
|
};
|
|
|
|
AddonManager.addAddonListener(listener);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* A helper method to install AddonInstall and wait for completion.
|
|
*
|
|
* @param {AddonInstall} install
|
|
* The add-on to install.
|
|
* @returns {Promise}
|
|
* Resolves when the install completes, either successfully or
|
|
* in failure.
|
|
*/
|
|
promiseCompleteInstall(install) {
|
|
let listener;
|
|
return new Promise(resolve => {
|
|
listener = {
|
|
onDownloadFailed: resolve,
|
|
onDownloadCancelled: resolve,
|
|
onInstallFailed: resolve,
|
|
onInstallCancelled: resolve,
|
|
onInstallEnded: resolve,
|
|
onInstallPostponed: resolve,
|
|
};
|
|
|
|
install.addListener(listener);
|
|
install.install();
|
|
}).then(() => {
|
|
install.removeListener(listener);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* A helper method to install a file.
|
|
*
|
|
* @param {nsIFile} file
|
|
* The file to install
|
|
* @param {boolean} [ignoreIncompatible = false]
|
|
* Optional parameter to ignore add-ons that are incompatible
|
|
* with the application
|
|
* @returns {Promise}
|
|
* Resolves when the install has completed.
|
|
*/
|
|
promiseInstallFile(file, ignoreIncompatible = false) {
|
|
return new Promise((resolve, reject) => {
|
|
AddonManager.getInstallForFile(file, install => {
|
|
if (!install)
|
|
reject(new Error(`No AddonInstall created for ${file.path}`));
|
|
else if (install.state != AddonManager.STATE_DOWNLOADED)
|
|
reject(new Error(`Expected file to be downloaded for install of ${file.path}`));
|
|
else if (ignoreIncompatible && install.addon.appDisabled)
|
|
resolve();
|
|
else
|
|
resolve(this.promiseCompleteInstall(install));
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* A helper method to install an array of files.
|
|
*
|
|
* @param {Iterable<nsIFile>} files
|
|
* The files to install
|
|
* @param {boolean} [ignoreIncompatible = false]
|
|
* Optional parameter to ignore add-ons that are incompatible
|
|
* with the application
|
|
* @returns {Promise}
|
|
* Resolves when the installs have completed.
|
|
*/
|
|
promiseInstallAllFiles(files, ignoreIncompatible = false) {
|
|
return Promise.all(Array.from(
|
|
files,
|
|
file => this.promiseInstallFile(file, ignoreIncompatible)));
|
|
},
|
|
|
|
promiseCompleteAllInstalls(installs) {
|
|
return Promise.all(Array.from(installs, this.promiseCompleteInstall));
|
|
},
|
|
|
|
/**
|
|
* A promise-based variant of AddonManager.getAddonsByIDs.
|
|
*
|
|
* @param {Array<string>} list
|
|
* As the first argument of AddonManager.getAddonsByIDs
|
|
* @return {Promise<Array<Addon>>}
|
|
* Resolves to the array of add-ons for the given IDs.
|
|
*/
|
|
promiseAddonsByIDs(list) {
|
|
return new Promise(resolve => AddonManager.getAddonsByIDs(list, resolve));
|
|
},
|
|
|
|
/**
|
|
* A promise-based variant of AddonManager.getAddonByID.
|
|
*
|
|
* @param {string} id
|
|
* The ID of the add-on.
|
|
* @return {Promise<Addon>}
|
|
* Resolves to the add-on with the given ID.
|
|
*/
|
|
promiseAddonByID(id) {
|
|
return new Promise(resolve => AddonManager.getAddonByID(id, resolve));
|
|
},
|
|
|
|
/**
|
|
* Returns a promise that will be resolved when an add-on update check is
|
|
* complete. The value resolved will be an AddonInstall if a new version was
|
|
* found.
|
|
*
|
|
* @param {object} addon The add-on to find updates for.
|
|
* @param {integer} reason The type of update to find.
|
|
* @return {Promise<object>} an object containing information about the update.
|
|
*/
|
|
promiseFindAddonUpdates(addon, reason = AddonManager.UPDATE_WHEN_PERIODIC_UPDATE) {
|
|
let equal = this.testScope.equal;
|
|
return new Promise((resolve, reject) => {
|
|
let result = {};
|
|
addon.findUpdates({
|
|
onNoCompatibilityUpdateAvailable: function(addon2) {
|
|
if ("compatibilityUpdate" in result) {
|
|
throw new Error("Saw multiple compatibility update events");
|
|
}
|
|
equal(addon, addon2, "onNoCompatibilityUpdateAvailable");
|
|
result.compatibilityUpdate = false;
|
|
},
|
|
|
|
onCompatibilityUpdateAvailable: function(addon2) {
|
|
if ("compatibilityUpdate" in result) {
|
|
throw new Error("Saw multiple compatibility update events");
|
|
}
|
|
equal(addon, addon2, "onCompatibilityUpdateAvailable");
|
|
result.compatibilityUpdate = true;
|
|
},
|
|
|
|
onNoUpdateAvailable: function(addon2) {
|
|
if ("updateAvailable" in result) {
|
|
throw new Error("Saw multiple update available events");
|
|
}
|
|
equal(addon, addon2, "onNoUpdateAvailable");
|
|
result.updateAvailable = false;
|
|
},
|
|
|
|
onUpdateAvailable: function(addon2, install) {
|
|
if ("updateAvailable" in result) {
|
|
throw new Error("Saw multiple update available events");
|
|
}
|
|
equal(addon, addon2, "onUpdateAvailable");
|
|
result.updateAvailable = install;
|
|
},
|
|
|
|
onUpdateFinished: function(addon2, error) {
|
|
equal(addon, addon2, "onUpdateFinished");
|
|
if (error == AddonManager.UPDATE_STATUS_NO_ERROR) {
|
|
resolve(result);
|
|
} else {
|
|
result.error = error;
|
|
reject(result);
|
|
}
|
|
}
|
|
}, reason);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* A promise-based variant of AddonManager.getAddonsWithOperationsByTypes
|
|
*
|
|
* @param {Array<string>} types
|
|
* The first argument to AddonManager.getAddonsWithOperationsByTypes
|
|
* @return {Promise<Array<Addon>>}
|
|
* Resolves to an array of add-ons with the given operations
|
|
* pending.
|
|
*/
|
|
promiseAddonsWithOperationsByTypes(types) {
|
|
return new Promise(resolve => AddonManager.getAddonsWithOperationsByTypes(types, resolve));
|
|
},
|
|
|
|
/**
|
|
* Monitors console output for the duration of a task, and returns a promise
|
|
* which resolves to a tuple containing a list of all console messages
|
|
* generated during the task's execution, and the result of the task itself.
|
|
*
|
|
* @param {function} task
|
|
* The task to run while monitoring console output. May be
|
|
* either a generator function, per Task.jsm, or an ordinary
|
|
* function which returns promose.
|
|
* @return {Promise<[Array<nsIConsoleMessage>, *]>}
|
|
* Resolves to an object containing a `messages` property, with
|
|
* the array of console messages emitted during the execution
|
|
* of the task, and a `result` property, containing the task's
|
|
* return value.
|
|
*/
|
|
promiseConsoleOutput: Task.async(function*(task) {
|
|
const DONE = "=== xpcshell test console listener done ===";
|
|
|
|
let listener, messages = [];
|
|
let awaitListener = new Promise(resolve => {
|
|
listener = msg => {
|
|
if (msg == DONE) {
|
|
resolve();
|
|
} else {
|
|
msg instanceof Ci.nsIScriptError;
|
|
messages.push(msg);
|
|
}
|
|
};
|
|
});
|
|
|
|
Services.console.registerListener(listener);
|
|
try {
|
|
let result = yield task();
|
|
|
|
Services.console.logStringMessage(DONE);
|
|
yield awaitListener;
|
|
|
|
return {messages, result};
|
|
} finally {
|
|
Services.console.unregisterListener(listener);
|
|
}
|
|
}),
|
|
};
|
|
|
|
for (let [key, val] of Object.entries(AddonTestUtils)) {
|
|
if (typeof val == "function")
|
|
AddonTestUtils[key] = val.bind(AddonTestUtils);
|
|
}
|
|
|
|
EventEmitter.decorate(AddonTestUtils);
|