diff options
Diffstat (limited to 'toolkit/mozapps/webextensions/internal/AddonTestUtils.jsm')
-rw-r--r-- | toolkit/mozapps/webextensions/internal/AddonTestUtils.jsm | 1231 |
1 files changed, 1231 insertions, 0 deletions
diff --git a/toolkit/mozapps/webextensions/internal/AddonTestUtils.jsm b/toolkit/mozapps/webextensions/internal/AddonTestUtils.jsm new file mode 100644 index 000000000..6422929b1 --- /dev/null +++ b/toolkit/mozapps/webextensions/internal/AddonTestUtils.jsm @@ -0,0 +1,1231 @@ +/* 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); |