1953 lines
58 KiB
JavaScript
1953 lines
58 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, {"argsIgnorePattern": "^_", "varsIgnorePattern": "^(Cc|Ci|Cr|Cu|EXPORTED_SYMBOLS)$"}] */
|
|
/* eslint "semi": [2, "always"] */
|
|
/* eslint "valid-jsdoc": [2, {requireReturn: false}] */
|
|
|
|
const CERTDB_CONTRACTID = "@mozilla.org/security/x509certdb;1";
|
|
|
|
import {
|
|
AddonManager,
|
|
AddonManagerPrivate,
|
|
} from "resource://gre/modules/AddonManager.sys.mjs";
|
|
import { AsyncShutdown } from "resource://gre/modules/AsyncShutdown.sys.mjs";
|
|
import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
|
|
import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs";
|
|
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
|
import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
ExtensionAddonObserver: "resource://gre/modules/Extension.sys.mjs",
|
|
ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs",
|
|
FileTestUtils: "resource://testing-common/FileTestUtils.sys.mjs",
|
|
Management: "resource://gre/modules/Extension.sys.mjs",
|
|
MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs",
|
|
|
|
XPCShellContentUtils:
|
|
"resource://testing-common/XPCShellContentUtils.sys.mjs",
|
|
|
|
getAppInfo: "resource://testing-common/AppInfo.sys.mjs",
|
|
updateAppInfo: "resource://testing-common/AppInfo.sys.mjs",
|
|
});
|
|
|
|
XPCOMUtils.defineLazyServiceGetters(lazy, {
|
|
aomStartup: [
|
|
"@mozilla.org/addons/addon-manager-startup;1",
|
|
"amIAddonManagerStartup",
|
|
],
|
|
});
|
|
|
|
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 ZipReader = Components.Constructor(
|
|
"@mozilla.org/libjar/zip-reader;1",
|
|
"nsIZipReader",
|
|
"open"
|
|
);
|
|
|
|
const ZipWriter = Components.Constructor(
|
|
"@mozilla.org/zipwriter;1",
|
|
"nsIZipWriter",
|
|
"open"
|
|
);
|
|
|
|
function isRegExp(val) {
|
|
return val && typeof val === "object" && typeof val.test === "function";
|
|
}
|
|
|
|
class MockBarrier {
|
|
constructor(name) {
|
|
this.name = name;
|
|
this.blockers = [];
|
|
}
|
|
|
|
addBlocker(name, blocker, options) {
|
|
this.blockers.push({ name, blocker, options });
|
|
}
|
|
|
|
async trigger() {
|
|
await Promise.all(
|
|
this.blockers.map(async ({ blocker, name }) => {
|
|
try {
|
|
if (typeof blocker == "function") {
|
|
await blocker();
|
|
} else {
|
|
await blocker;
|
|
}
|
|
} catch (e) {
|
|
Cu.reportError(e);
|
|
dump(
|
|
`Shutdown blocker '${name}' for ${this.name} threw error: ${e} :: ${e.stack}\n`
|
|
);
|
|
}
|
|
})
|
|
);
|
|
|
|
this.blockers = [];
|
|
}
|
|
}
|
|
|
|
// Mock out AddonManager's reference to the AsyncShutdown module so we can shut
|
|
// down AddonManager from the test
|
|
export var MockAsyncShutdown = {
|
|
profileBeforeChange: new MockBarrier("profileBeforeChange"),
|
|
profileChangeTeardown: new MockBarrier("profileChangeTeardown"),
|
|
appShutdownConfirmed: new MockBarrier("appShutdownConfirmed"),
|
|
// We can use the real Barrier
|
|
Barrier: AsyncShutdown.Barrier,
|
|
};
|
|
|
|
AddonManagerPrivate.overrideAsyncShutdown(MockAsyncShutdown);
|
|
|
|
class AddonsList {
|
|
constructor(file) {
|
|
this.extensions = [];
|
|
this.themes = [];
|
|
this.xpis = [];
|
|
|
|
if (!file.exists()) {
|
|
return;
|
|
}
|
|
|
|
let data = lazy.aomStartup.readStartupData();
|
|
|
|
for (let loc of Object.values(data)) {
|
|
let dir = loc.path && new nsFile(loc.path);
|
|
|
|
for (let addon of Object.values(loc.addons)) {
|
|
let file;
|
|
if (dir) {
|
|
file = dir.clone();
|
|
try {
|
|
file.appendRelativePath(addon.path);
|
|
} catch (e) {
|
|
file = new nsFile(addon.path);
|
|
}
|
|
} else if (addon.path) {
|
|
file = new nsFile(addon.path);
|
|
}
|
|
|
|
if (!file) {
|
|
continue;
|
|
}
|
|
|
|
this.xpis.push(file);
|
|
|
|
if (addon.enabled) {
|
|
addon.type = addon.type || "extension";
|
|
|
|
if (addon.type == "theme") {
|
|
this.themes.push(file);
|
|
} else {
|
|
this.extensions.push(file);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 addonStartup.json: ${file.path}`
|
|
);
|
|
}
|
|
|
|
if (file.isDirectory()) {
|
|
return file.equals(path);
|
|
}
|
|
if (file.isFile()) {
|
|
return file.equals(xpiPath);
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
hasTheme(dir, id) {
|
|
return this.hasItem("themes", dir, id);
|
|
}
|
|
|
|
hasExtension(dir, id) {
|
|
return this.hasItem("extensions", dir, id);
|
|
}
|
|
}
|
|
|
|
// The number of resetXPIExports calls.
|
|
//
|
|
// This is added to the URL of the modules once resetXPIExports is called,
|
|
// so that they become different module instances for each reset, and also the
|
|
// suffix is not used outside of tests.
|
|
let resetXPIExportsCount = 0;
|
|
|
|
// Reset all properties of XPIExports to lazy getters, with new module URIs,
|
|
// in order to simulate the shutdown+restart situation.
|
|
function resetXPIExports(XPIExports) {
|
|
resetXPIExportsCount++;
|
|
|
|
const suffix = "?" + resetXPIExportsCount;
|
|
|
|
// The list of lazy getters should be in sync with XPIExports.sys.mjs.
|
|
//
|
|
// eslint-disable-next-line mozilla/lazy-getter-object-name
|
|
ChromeUtils.defineESModuleGetters(XPIExports, {
|
|
// XPIDatabase.sys.mjs
|
|
AddonInternal: "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
|
|
BuiltInThemesHelpers:
|
|
"resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
|
|
XPIDatabase: "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
|
|
XPIDatabaseReconcile:
|
|
"resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
|
|
|
|
// XPIInstall.sys.mjs
|
|
UpdateChecker: "resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix,
|
|
XPIInstall: "resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix,
|
|
verifyBundleSignedState:
|
|
"resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix,
|
|
|
|
// XPIProvider.sys.mjs
|
|
XPIProvider: "resource://gre/modules/addons/XPIProvider.sys.mjs" + suffix,
|
|
XPIInternal: "resource://gre/modules/addons/XPIProvider.sys.mjs" + suffix,
|
|
});
|
|
}
|
|
|
|
export var AddonTestUtils = {
|
|
addonIntegrationService: null,
|
|
addonsList: null,
|
|
appInfo: null,
|
|
addonStartup: null,
|
|
collectedTelemetryEvents: [],
|
|
testScope: null,
|
|
testUnpacked: false,
|
|
useRealCertChecks: false,
|
|
usePrivilegedSignatures: true,
|
|
certSignatureDate: null,
|
|
overrideEntry: null,
|
|
|
|
maybeInit(testScope) {
|
|
if (this.testScope != testScope) {
|
|
this.init(testScope);
|
|
}
|
|
},
|
|
|
|
init(testScope, enableLogging = true) {
|
|
if (this.testScope === testScope) {
|
|
return;
|
|
}
|
|
this.testScope = testScope;
|
|
|
|
// Get the profile directory for tests to use.
|
|
this.profileDir = testScope.do_get_profile();
|
|
|
|
this.profileExtensions = this.profileDir.clone();
|
|
this.profileExtensions.append("extensions");
|
|
|
|
this.addonStartup = this.profileDir.clone();
|
|
this.addonStartup.append("addonStartup.json.lz4");
|
|
|
|
// 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.
|
|
if (enableLogging) {
|
|
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);
|
|
|
|
// And scan for changes at startup
|
|
Services.prefs.setIntPref("extensions.startupScanScopes", 15);
|
|
|
|
// By default, don't cache add-ons in AddonRepository.sys.mjs
|
|
Services.prefs.setBoolPref("extensions.getAddons.cache.enabled", 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(
|
|
"services.settings.server",
|
|
"data:,#remote-settings-dummy/v1"
|
|
);
|
|
|
|
// By default ignore bundled add-ons
|
|
Services.prefs.setBoolPref("extensions.installDistroAddons", false);
|
|
|
|
// Ensure signature checks are enabled by default
|
|
Services.prefs.setBoolPref("xpinstall.signatures.required", true);
|
|
|
|
// 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.registerCleanupFunction(() => {
|
|
// Force a GC to ensure that anything holding a ref to temp file releases it.
|
|
// XXX This shouldn't be needed here, since cleanupTempXPIs() does a GC if
|
|
// something fails; see bug 1761255
|
|
this.info(`Force a GC`);
|
|
Cu.forceGC();
|
|
|
|
this.cleanupTempXPIs();
|
|
|
|
let ignoreEntries = new Set();
|
|
{
|
|
// FileTestUtils lazily creates a directory to hold the temporary files
|
|
// it creates. If that directory exists, ignore it.
|
|
let { value } = Object.getOwnPropertyDescriptor(
|
|
lazy.FileTestUtils,
|
|
"_globalTemporaryDirectory"
|
|
);
|
|
if (value) {
|
|
ignoreEntries.add(value.leafName);
|
|
}
|
|
}
|
|
|
|
// Check that the temporary directory is empty
|
|
var entries = [];
|
|
for (let { leafName } of this.iterDirectory(this.tempDir)) {
|
|
if (!ignoreEntries.has(leafName)) {
|
|
entries.push(leafName);
|
|
}
|
|
}
|
|
if (entries.length) {
|
|
throw new Error(
|
|
`Found unexpected files in temporary directory: ${entries.join(", ")}`
|
|
);
|
|
}
|
|
|
|
try {
|
|
appDirForAddons.remove(true);
|
|
} catch (ex) {
|
|
testScope.info(`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/
|
|
for (let dir of this.iterDirectory(featuresDir)) {
|
|
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();
|
|
});
|
|
},
|
|
|
|
initMochitest(testScope) {
|
|
if (this.testScope === testScope) {
|
|
return;
|
|
}
|
|
this.testScope = testScope;
|
|
|
|
this.profileDir = FileUtils.getDir("ProfD", []);
|
|
|
|
this.profileExtensions = FileUtils.getDir("ProfD", ["extensions"]);
|
|
|
|
this.tempDir = FileUtils.getDir("TmpD", []);
|
|
this.tempDir.append("addons-mochitest");
|
|
this.tempDir.createUnique(
|
|
Ci.nsIFile.DIRECTORY_TYPE,
|
|
FileUtils.PERMS_DIRECTORY
|
|
);
|
|
|
|
testScope.registerCleanupFunction(() => {
|
|
// Defer testScope cleanup until the last cleanup function has run.
|
|
testScope.registerCleanupFunction(() => {
|
|
this.testScope = null;
|
|
});
|
|
this.cleanupTempXPIs();
|
|
try {
|
|
this.tempDir.remove(true);
|
|
} catch (e) {
|
|
Cu.reportError(e);
|
|
}
|
|
});
|
|
},
|
|
|
|
getXPIExports() {
|
|
return ChromeUtils.importESModule(
|
|
"resource://gre/modules/addons/XPIExports.sys.mjs"
|
|
).XPIExports;
|
|
},
|
|
|
|
getWeakSignatureInstallPrefName() {
|
|
return this.getXPIExports().XPIInstall.getWeakSignatureInstallPrefName();
|
|
},
|
|
|
|
setWeakSignatureInstallAllowed(allowed) {
|
|
const prefName = this.getWeakSignatureInstallPrefName();
|
|
let cleanupCalled = false;
|
|
const cleanup = () => {
|
|
if (cleanupCalled) {
|
|
return;
|
|
}
|
|
this.testScope.info(
|
|
`=== clear ${prefName} pref value set by this test file ===`
|
|
);
|
|
Services.prefs.clearUserPref(prefName);
|
|
cleanupCalled = true;
|
|
};
|
|
this.testScope.registerCleanupFunction(cleanup);
|
|
this.testScope.info(`=== set ${prefName} pref value to ${allowed} ===`);
|
|
Services.prefs.setBoolPref(prefName, allowed);
|
|
return cleanup;
|
|
},
|
|
|
|
/**
|
|
* Iterates over the entries in a given directory.
|
|
*
|
|
* Fails silently if the given directory does not exist.
|
|
*
|
|
* @param {nsIFile} dir
|
|
* Directory to iterate.
|
|
*/
|
|
*iterDirectory(dir) {
|
|
let dirEnum;
|
|
try {
|
|
dirEnum = dir.directoryEntries;
|
|
let file;
|
|
while ((file = dirEnum.nextFile)) {
|
|
yield file;
|
|
}
|
|
} catch (e) {
|
|
if (dir.exists()) {
|
|
Cu.reportError(e);
|
|
}
|
|
} finally {
|
|
if (dirEnum) {
|
|
dirEnum.close();
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Creates a new HttpServer for testing, and begins listening on the
|
|
* specified port. Automatically shuts down the server when the test
|
|
* unit ends.
|
|
*
|
|
* @param {object} [options = {}]
|
|
* The options object.
|
|
* @param {integer} [options.port = -1]
|
|
* The port to listen on. If omitted, listen on a random
|
|
* port. The latter is the preferred behavior.
|
|
* @param {sequence<string>?} [options.hosts = null]
|
|
* A set of hosts to accept connections to. Support for this is
|
|
* implemented using a proxy filter.
|
|
*
|
|
* @returns {HttpServer}
|
|
* The HTTP server instance.
|
|
*/
|
|
createHttpServer(...args) {
|
|
lazy.XPCShellContentUtils.ensureInitialized(this.testScope);
|
|
return lazy.XPCShellContentUtils.createHttpServer(...args);
|
|
},
|
|
|
|
registerJSON(...args) {
|
|
return lazy.XPCShellContentUtils.registerJSON(...args);
|
|
},
|
|
|
|
info(msg) {
|
|
// info() for mochitests, do_print for xpcshell.
|
|
let print = this.testScope.info || this.testScope.do_print;
|
|
print(msg);
|
|
},
|
|
|
|
cleanupTempXPIs() {
|
|
let didGC = false;
|
|
|
|
for (let file of this.tempXPIs.splice(0)) {
|
|
if (file.exists()) {
|
|
try {
|
|
Services.obs.notifyObservers(file, "flush-cache-entry");
|
|
file.remove(false);
|
|
} catch (e) {
|
|
if (didGC) {
|
|
Cu.reportError(`Failed to remove ${file.path}: ${e}`);
|
|
} else {
|
|
// Bug 1606684 - Sometimes XPI files are still in use by a process
|
|
// after the test has been finished. Force a GC once and try again.
|
|
this.info(`Force a GC`);
|
|
Cu.forceGC();
|
|
didGC = true;
|
|
|
|
try {
|
|
file.remove(false);
|
|
} catch (e) {
|
|
Cu.reportError(`Failed to remove ${file.path} after GC: ${e}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
createAppInfo(ID, name, version, platformVersion = "1.0") {
|
|
lazy.updateAppInfo({
|
|
ID,
|
|
name,
|
|
version,
|
|
platformVersion,
|
|
crashReporter: true,
|
|
});
|
|
this.appInfo = lazy.getAppInfo();
|
|
},
|
|
|
|
updateAppInfo(appInfoProps = {}) {
|
|
const {
|
|
ID = "xpcshell@tests.mozilla.org",
|
|
name = "XPCShell",
|
|
version = "1",
|
|
platformVersion = "1.0",
|
|
} = appInfoProps;
|
|
lazy.updateAppInfo({
|
|
ID,
|
|
name,
|
|
version,
|
|
platformVersion,
|
|
crashReporter: true,
|
|
...appInfoProps,
|
|
});
|
|
this.appInfo = lazy.getAppInfo();
|
|
},
|
|
|
|
getManifestURI(file) {
|
|
if (file.isDirectory()) {
|
|
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("manifest.json")) {
|
|
return NetUtil.newURI(`jar:${uri.spec}!/manifest.json`);
|
|
}
|
|
|
|
throw new Error("No manifest file present");
|
|
} finally {
|
|
zip.close();
|
|
}
|
|
},
|
|
|
|
getIDFromExtension(file) {
|
|
return this.getIDFromManifest(this.getManifestURI(file));
|
|
},
|
|
|
|
async getIDFromManifest(manifestURI) {
|
|
let body = await fetch(manifestURI.spec);
|
|
let manifest = await body.json();
|
|
try {
|
|
if (manifest.browser_specific_settings?.gecko?.id) {
|
|
return manifest.browser_specific_settings.gecko.id;
|
|
}
|
|
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 Services.uuid.generateUUID().number;
|
|
}
|
|
},
|
|
|
|
overrideCertDB() {
|
|
let verifyCert = async (file, result, signatureInfos, 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 id;
|
|
try {
|
|
let manifestURI = this.getManifestURI(file);
|
|
id = await this.getIDFromManifest(manifestURI);
|
|
} catch (err) {
|
|
if (file.leafName.endsWith(".xpi")) {
|
|
id = file.leafName.slice(0, -4);
|
|
}
|
|
}
|
|
|
|
let fakeCert = { commonName: id };
|
|
if (this.usePrivilegedSignatures) {
|
|
let privileged =
|
|
typeof this.usePrivilegedSignatures == "function"
|
|
? this.usePrivilegedSignatures(id)
|
|
: this.usePrivilegedSignatures;
|
|
if (privileged === "system") {
|
|
fakeCert.organizationalUnit = "Mozilla Components";
|
|
} else if (privileged) {
|
|
fakeCert.organizationalUnit = "Mozilla Extensions";
|
|
}
|
|
}
|
|
if (this.certSignatureDate) {
|
|
// addon.signedDate is derived from this, used by the blocklist.
|
|
fakeCert.validity = {
|
|
notBefore: this.certSignatureDate * 1000,
|
|
};
|
|
}
|
|
|
|
return [
|
|
callback,
|
|
Cr.NS_OK,
|
|
[
|
|
{
|
|
signerCert: fakeCert,
|
|
signatureAlgorithm: Ci.nsIAppSignatureInfo.COSE_WITH_SHA256,
|
|
},
|
|
],
|
|
];
|
|
} 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, signatureInfos];
|
|
};
|
|
|
|
let FakeCertDB = {
|
|
init() {
|
|
for (let property of Object.keys(
|
|
this._genuine.QueryInterface(Ci.nsIX509CertDB)
|
|
)) {
|
|
if (property in this) {
|
|
continue;
|
|
}
|
|
|
|
if (typeof this._genuine[property] == "function") {
|
|
this[property] = this._genuine[property].bind(this._genuine);
|
|
}
|
|
}
|
|
},
|
|
|
|
openSignedAppFileAsync(root, file, callback) {
|
|
// First try calling the real cert DB
|
|
this._genuine.openSignedAppFileAsync(
|
|
root,
|
|
file,
|
|
(result, zipReader, signatureInfos) => {
|
|
verifyCert(file.clone(), result, signatureInfos, callback).then(
|
|
([callback, result, signatureInfos]) => {
|
|
callback.openSignedAppFileFinished(
|
|
result,
|
|
zipReader,
|
|
signatureInfos
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
},
|
|
|
|
QueryInterface: ChromeUtils.generateQI(["nsIX509CertDB"]),
|
|
};
|
|
|
|
// Unregister the real database. This only works because the add-ons manager
|
|
// hasn't started up and grabbed the certificate database yet.
|
|
lazy.MockRegistrar.register(CERTDB_CONTRACTID, FakeCertDB);
|
|
|
|
// Initialize the mock service.
|
|
Cc[CERTDB_CONTRACTID].getService();
|
|
FakeCertDB.init();
|
|
},
|
|
|
|
/**
|
|
* Load the data from the specified files into the *real* blocklist providers.
|
|
* Loads using loadBlocklistRawData, which will treat this as an update.
|
|
*
|
|
* @param {nsIFile} dir
|
|
* The directory in which the files live.
|
|
* @param {string} prefix
|
|
* a prefix for the files which ought to be loaded.
|
|
* This method will suffix -extensions.json
|
|
* to the prefix it is given, and attempt to load it.
|
|
* If it exists, its data will be dumped into
|
|
* the respective store, and the update handler
|
|
* will be called.
|
|
*/
|
|
async loadBlocklistData(dir, prefix) {
|
|
let loadedData = {};
|
|
let fileSuffix = "extensions";
|
|
const fileName = `${prefix}-${fileSuffix}.json`;
|
|
|
|
try {
|
|
loadedData[fileSuffix] = await IOUtils.readJSON(
|
|
PathUtils.join(dir.path, fileName)
|
|
);
|
|
this.info(`Loaded ${fileName}`);
|
|
} catch (e) {}
|
|
|
|
return this.loadBlocklistRawData(loadedData);
|
|
},
|
|
|
|
/**
|
|
* Load the following data into the *real* blocklist providers.
|
|
* Fires update methods as would happen if this data came from
|
|
* an actual blocklist update, etc.
|
|
*
|
|
* @param {object} data
|
|
* The data to load.
|
|
*/
|
|
async loadBlocklistRawData(data) {
|
|
const { BlocklistPrivate } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/Blocklist.sys.mjs"
|
|
);
|
|
const blocklistMapping = {
|
|
extensions: BlocklistPrivate.ExtensionBlocklistRS,
|
|
extensionsMLBF: BlocklistPrivate.ExtensionBlocklistMLBF,
|
|
};
|
|
|
|
for (const [dataProp, blocklistObj] of Object.entries(blocklistMapping)) {
|
|
let newData = data[dataProp];
|
|
if (!newData) {
|
|
continue;
|
|
}
|
|
if (!Array.isArray(newData)) {
|
|
throw new Error(
|
|
"Expected an array of new items to put in the " +
|
|
dataProp +
|
|
" blocklist!"
|
|
);
|
|
}
|
|
for (let item of newData) {
|
|
if (!item.id) {
|
|
item.id = Services.uuid.generateUUID().number.slice(1, -1);
|
|
}
|
|
if (!item.last_modified) {
|
|
item.last_modified = Date.now();
|
|
}
|
|
}
|
|
blocklistObj.ensureInitialized();
|
|
let db = await blocklistObj._client.db;
|
|
const collectionTimestamp = Math.max(
|
|
...newData.map(r => r.last_modified)
|
|
);
|
|
await db.importChanges({}, collectionTimestamp, newData, {
|
|
clear: true,
|
|
});
|
|
// We manually call _onUpdate... which is evil, but at the moment kinto doesn't have
|
|
// a better abstraction unless you want to mock your own http server to do the update.
|
|
await blocklistObj._onUpdate();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Starts up the add-on manager as if it was started by the application.
|
|
*
|
|
* @param {Object} params
|
|
* The new params are in an object and new code should use that.
|
|
* @param {boolean} params.earlyStartup
|
|
* Notifies early startup phase. default is true
|
|
* @param {boolean} params.lateStartup
|
|
* Notifies late startup phase which ensures addons are started or
|
|
* listeners are primed. default is true
|
|
* @param {boolean} params.newVersion
|
|
* If provided, the application version is changed to this string
|
|
* before the AddonManager is started.
|
|
*/
|
|
async promiseStartupManager(params) {
|
|
if (this.addonIntegrationService) {
|
|
throw new Error(
|
|
"Attempting to startup manager that was already started."
|
|
);
|
|
}
|
|
// Support old arguments
|
|
if (typeof params != "object") {
|
|
params = {
|
|
newVersion: arguments[0],
|
|
};
|
|
}
|
|
let { earlyStartup = true, lateStartup = true, newVersion } = params;
|
|
|
|
lateStartup = earlyStartup && lateStartup;
|
|
|
|
if (newVersion) {
|
|
this.appInfo.version = newVersion;
|
|
this.appInfo.platformVersion = newVersion;
|
|
}
|
|
|
|
// AddonListeners are removed when the addonManager is shutdown,
|
|
// ensure the Extension observer is added. We call uninit in
|
|
// promiseShutdown to allow re-initialization.
|
|
lazy.ExtensionAddonObserver.init();
|
|
|
|
const { XPIExports } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/addons/XPIExports.sys.mjs"
|
|
);
|
|
XPIExports.XPIInternal.overrideAsyncShutdown(MockAsyncShutdown);
|
|
|
|
XPIExports.XPIInternal.BootstrapScope.prototype._beforeCallBootstrapMethod =
|
|
(method, params, reason) => {
|
|
try {
|
|
this.emit("bootstrap-method", { method, params, reason });
|
|
} catch (e) {
|
|
try {
|
|
this.testScope.do_throw(e);
|
|
} catch (e) {
|
|
// Le sigh.
|
|
}
|
|
}
|
|
};
|
|
|
|
this.addonIntegrationService = Cc[
|
|
"@mozilla.org/addons/integration;1"
|
|
].getService(Ci.nsIObserver);
|
|
|
|
this.addonIntegrationService.observe(null, "addons-startup", null);
|
|
|
|
this.emit("addon-manager-started");
|
|
|
|
await Promise.all(XPIExports.XPIProvider.startupPromises);
|
|
|
|
// Load the add-ons list as it was after extension registration
|
|
await this.loadAddonsList(true);
|
|
|
|
// Wait for all add-ons to finish starting up before resolving.
|
|
await Promise.all(
|
|
Array.from(
|
|
XPIExports.XPIProvider.activeAddons.values(),
|
|
addon => addon.startupPromise
|
|
)
|
|
);
|
|
if (earlyStartup) {
|
|
lazy.ExtensionTestCommon.notifyEarlyStartup();
|
|
}
|
|
if (lateStartup) {
|
|
lazy.ExtensionTestCommon.notifyLateStartup();
|
|
}
|
|
},
|
|
|
|
async promiseShutdownManager({
|
|
clearOverrides = true,
|
|
clearL10nRegistry = true,
|
|
} = {}) {
|
|
if (!this.addonIntegrationService) {
|
|
return false;
|
|
}
|
|
|
|
if (this.overrideEntry && clearOverrides) {
|
|
this.overrideEntry.destruct();
|
|
this.overrideEntry = null;
|
|
}
|
|
|
|
const { XPIExports } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/addons/XPIExports.sys.mjs"
|
|
);
|
|
|
|
// Ensure some startup observers in XPIProvider are released.
|
|
Services.obs.notifyObservers(null, "test-load-xpi-database");
|
|
|
|
// Note: the code here used to trigger observer notifications such as
|
|
// "quit-application-granted". That was changed in bug 1845352 because of
|
|
// unwanted side effects in other components. The MockAsyncShutdown
|
|
// triggers here are very specific and only affect the AddonManager/
|
|
// XPIProvider internals.
|
|
await MockAsyncShutdown.appShutdownConfirmed.trigger();
|
|
|
|
// If XPIDatabase.asyncLoadDB() has been called before, then _dbPromise is
|
|
// a promise, potentially still pending. Wait for it to settle before
|
|
// triggering profileBeforeChange, because the latter can trigger errors in
|
|
// the pending asyncLoadDB() by an indirect call to XPIDatabase.shutdown().
|
|
await XPIExports.XPIDatabase._dbPromise;
|
|
|
|
await MockAsyncShutdown.profileBeforeChange.trigger();
|
|
await MockAsyncShutdown.profileChangeTeardown.trigger();
|
|
|
|
this.emit("addon-manager-shutdown");
|
|
|
|
this.addonIntegrationService = null;
|
|
|
|
// Load the add-ons list as it was after application shutdown
|
|
await this.loadAddonsList();
|
|
|
|
// Flush the jar cache entries for each bootstrapped XPI so that
|
|
// we don't run into file locking issues on Windows.
|
|
for (let file of this.addonsList.xpis) {
|
|
Services.obs.notifyObservers(file, "flush-cache-entry");
|
|
}
|
|
|
|
// Clear L10nRegistry entries so restaring the AOM will work correctly with locales.
|
|
if (clearL10nRegistry) {
|
|
L10nRegistry.getInstance().clearSources();
|
|
}
|
|
|
|
// Clear any crash report annotations
|
|
this.appInfo.annotations = {};
|
|
|
|
// Force the XPIProvider provider to reload to better
|
|
// simulate real-world usage.
|
|
|
|
// This would be cleaner if I could get it as the rejection reason from
|
|
// the AddonManagerInternal.shutdown() promise
|
|
let shutdownError = XPIExports.XPIDatabase._saveError;
|
|
|
|
AddonManagerPrivate.unregisterProvider(XPIExports.XPIProvider);
|
|
|
|
resetXPIExports(XPIExports);
|
|
|
|
lazy.ExtensionAddonObserver.uninit();
|
|
|
|
lazy.ExtensionTestCommon.resetStartupPromises();
|
|
|
|
if (shutdownError) {
|
|
throw shutdownError;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Asynchronously restart the AddonManager. If newVersion is provided,
|
|
* simulate an application upgrade (or downgrade) where the version
|
|
* is changed to newVersion when re-started.
|
|
*
|
|
* @param {Object} params
|
|
* The new params are in an object and new code should use that.
|
|
* See promiseStartupManager for param details.
|
|
*/
|
|
async promiseRestartManager(params) {
|
|
await this.promiseShutdownManager({ clearOverrides: false });
|
|
await this.promiseStartupManager(params);
|
|
},
|
|
|
|
/**
|
|
* If promiseStartupManager is called with earlyStartup: false, then
|
|
* use this to notify early startup.
|
|
*
|
|
* @returns {Promise} resolves when notification is complete
|
|
*/
|
|
notifyEarlyStartup() {
|
|
return lazy.ExtensionTestCommon.notifyEarlyStartup();
|
|
},
|
|
|
|
/**
|
|
* If promiseStartupManager is called with lateStartup: false, then
|
|
* use this to notify late startup. You should also call early startup
|
|
* if necessary.
|
|
*
|
|
* @returns {Promise} resolves when notification is complete
|
|
*/
|
|
notifyLateStartup() {
|
|
return lazy.ExtensionTestCommon.notifyLateStartup();
|
|
},
|
|
|
|
async loadAddonsList(flush = false) {
|
|
if (flush) {
|
|
const { XPIExports } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/addons/XPIExports.sys.mjs"
|
|
);
|
|
XPIExports.XPIInternal.XPIStates.save();
|
|
await XPIExports.XPIInternal.XPIStates._jsonFile._save();
|
|
}
|
|
|
|
this.addonsList = new AddonsList(this.addonStartup);
|
|
},
|
|
|
|
/**
|
|
* 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 (
|
|
typeof data === "object" &&
|
|
ChromeUtils.getClassName(data) === "Object"
|
|
) {
|
|
data = JSON.stringify(data);
|
|
}
|
|
if (!(data instanceof ArrayBuffer)) {
|
|
data = new TextEncoder().encode(data).buffer;
|
|
}
|
|
|
|
let stream = ArrayBufferInputStream(data, 0, data.byteLength);
|
|
|
|
// Note these files are being created in the XPI archive with date
|
|
// 1 << 49, which is a valid time for ZipWriter.
|
|
zipW.addEntryStream(
|
|
path,
|
|
Math.pow(2, 49),
|
|
Ci.nsIZipWriter.COMPRESSION_NONE,
|
|
stream,
|
|
false
|
|
);
|
|
}
|
|
|
|
zipW.close();
|
|
},
|
|
|
|
async promiseWriteFilesToZip(zip, files, flags) {
|
|
await IOUtils.makeDirectory(PathUtils.parent(zip));
|
|
|
|
this.writeFilesToZip(zip, files, flags);
|
|
|
|
return Promise.resolve(nsFile(zip));
|
|
},
|
|
|
|
async promiseWriteFilesToDir(dir, files) {
|
|
await IOUtils.makeDirectory(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 = PathUtils.join(dirPath, subDir);
|
|
await PathUtils.makeDirectory(dirPath);
|
|
}
|
|
|
|
const leafPath = PathUtils.join(dirPath, leafName);
|
|
if (
|
|
typeof data == "object" &&
|
|
ChromeUtils.getClassName(data) == "Object"
|
|
) {
|
|
await IOUtils.writeJSON(leafPath, data);
|
|
} else if (typeof data == "string") {
|
|
await IOUtils.writeUTF8(leafPath, data);
|
|
}
|
|
}
|
|
|
|
return nsFile(dir);
|
|
},
|
|
|
|
promiseWriteFilesToExtension(dir, id, files, unpacked = this.testUnpacked) {
|
|
if (unpacked) {
|
|
let path = PathUtils.join(dir, id);
|
|
|
|
return this.promiseWriteFilesToDir(path, files);
|
|
}
|
|
|
|
let xpi = PathUtils.join(dir, `${id}.xpi`);
|
|
|
|
return this.promiseWriteFilesToZip(xpi, files);
|
|
},
|
|
|
|
tempXPIs: [],
|
|
|
|
allocTempXPIFile() {
|
|
let file = this.tempDir.clone();
|
|
let uuid = Services.uuid.generateUUID().number.slice(1, -1);
|
|
file.append(`${uuid}.xpi`);
|
|
|
|
this.tempXPIs.push(file);
|
|
|
|
return file;
|
|
},
|
|
|
|
/**
|
|
* 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) {
|
|
let file = this.allocTempXPIFile();
|
|
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
|
|
* |ExtensionTestCommon.generateXPI|.
|
|
* @return {nsIFile} A file pointing to the created XPI file
|
|
*/
|
|
createTempWebExtensionFile(data) {
|
|
let file = lazy.ExtensionTestCommon.generateXPI(data);
|
|
this.tempXPIs.push(file);
|
|
return file;
|
|
},
|
|
|
|
/**
|
|
* Creates an XPI with the given files and installs it.
|
|
*
|
|
* @param {object} files
|
|
* A files object as would be passed to {@see #createTempXPI}.
|
|
* @returns {Promise}
|
|
* A promise which resolves when the add-on is installed.
|
|
*/
|
|
promiseInstallXPI(files) {
|
|
return this.promiseInstallFile(this.createTempXPIFile(files));
|
|
},
|
|
|
|
/**
|
|
* 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 = this.profileExtensions]
|
|
* 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.
|
|
*/
|
|
async manuallyInstall(
|
|
xpiFile,
|
|
installLocation = this.profileExtensions,
|
|
id = null,
|
|
unpacked = this.testUnpacked
|
|
) {
|
|
if (id == null) {
|
|
id = await this.getIDFromExtension(xpiFile);
|
|
}
|
|
|
|
if (unpacked) {
|
|
let dir = installLocation.clone();
|
|
dir.append(id);
|
|
dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
|
|
|
|
let zip = ZipReader(xpiFile);
|
|
for (let entry of zip.findEntries(null)) {
|
|
let target = dir.clone();
|
|
for (let part of entry.split("/")) {
|
|
target.append(part);
|
|
}
|
|
if (!target.parent.exists()) {
|
|
target.parent.create(
|
|
Ci.nsIFile.DIRECTORY_TYPE,
|
|
FileUtils.PERMS_DIRECTORY
|
|
);
|
|
}
|
|
try {
|
|
zip.extract(entry, target);
|
|
} catch (e) {
|
|
if (
|
|
e.result != Cr.NS_ERROR_FILE_DIR_NOT_EMPTY &&
|
|
!(target.exists() && target.isDirectory())
|
|
) {
|
|
throw e;
|
|
}
|
|
}
|
|
target.permissions |= FileUtils.PERMS_FILE;
|
|
}
|
|
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");
|
|
}
|
|
|
|
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.
|
|
*
|
|
* @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()) {
|
|
for (let file of this.iterDirectory(ext)) {
|
|
this.setExtensionModifiedTime(file, time);
|
|
}
|
|
}
|
|
},
|
|
|
|
async promiseSetExtensionModifiedTime(path, time) {
|
|
await IOUtils.setModificationTime(path, time);
|
|
|
|
const stat = await IOUtils.stat(path);
|
|
if (stat.type !== "directory") {
|
|
return;
|
|
}
|
|
|
|
const children = await IOUtils.getChildren(path);
|
|
|
|
try {
|
|
await Promise.all(
|
|
children.map(entry => this.promiseSetExtensionModifiedTime(entry, time))
|
|
);
|
|
} catch (ex) {
|
|
if (DOMException.isInstance(ex)) {
|
|
return;
|
|
}
|
|
throw ex;
|
|
}
|
|
},
|
|
|
|
registerDirectory(key, dir) {
|
|
var dirProvider = {
|
|
getFile(prop, persistent) {
|
|
persistent.value = false;
|
|
if (prop == key) {
|
|
return dir.clone();
|
|
}
|
|
return null;
|
|
},
|
|
|
|
QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]),
|
|
};
|
|
Services.dirsvc.registerProvider(dirProvider);
|
|
|
|
try {
|
|
Services.dirsvc.undefine(key);
|
|
} catch (e) {
|
|
// This throws if the key is not already registered, but that
|
|
// doesn't matter.
|
|
if (e.result != Cr.NS_ERROR_FAILURE) {
|
|
throw e;
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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.
|
|
* @param {function} checkFn [optional]
|
|
* A function to check if this is the right event. Should return true
|
|
* for the event that it wants, false otherwise. Will be passed
|
|
* all the relevant arguments.
|
|
* If not passed, any event will do to resolve the promise.
|
|
* @returns {Promise<Array>}
|
|
* Resolves to an array containing the event handler's
|
|
* arguments the first time it is called.
|
|
*/
|
|
promiseAddonEvent(event, checkFn) {
|
|
return new Promise(resolve => {
|
|
let listener = {
|
|
[event](...args) {
|
|
if (typeof checkFn == "function" && !checkFn(...args)) {
|
|
return;
|
|
}
|
|
AddonManager.removeAddonListener(listener);
|
|
resolve(args);
|
|
},
|
|
};
|
|
|
|
AddonManager.addAddonListener(listener);
|
|
});
|
|
},
|
|
|
|
promiseInstallEvent(event, checkFn) {
|
|
return new Promise(resolve => {
|
|
let listener = {
|
|
[event](...args) {
|
|
if (typeof checkFn == "function" && !checkFn(...args)) {
|
|
return;
|
|
}
|
|
AddonManager.removeInstallListener(listener);
|
|
resolve(args);
|
|
},
|
|
};
|
|
|
|
AddonManager.addInstallListener(listener);
|
|
});
|
|
},
|
|
|
|
promiseManagerEvent(event, checkFn) {
|
|
return new Promise(resolve => {
|
|
let listener = {
|
|
[event](...args) {
|
|
if (typeof checkFn == "function" && !checkFn(...args)) {
|
|
return;
|
|
}
|
|
AddonManager.removeManagerListener(listener);
|
|
resolve(args);
|
|
},
|
|
};
|
|
|
|
AddonManager.addManagerListener(listener);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* A helper method to install AddonInstall and wait for completion.
|
|
*
|
|
* @param {AddonInstall} install
|
|
* The add-on to install.
|
|
* @returns {Promise<AddonInstall>}
|
|
* Resolves when the install completes, either successfully or
|
|
* in failure.
|
|
*/
|
|
promiseCompleteInstall(install) {
|
|
let listener;
|
|
return new Promise(resolve => {
|
|
let installPromise;
|
|
listener = {
|
|
onDownloadFailed: resolve,
|
|
onDownloadCancelled: resolve,
|
|
onInstallFailed: resolve,
|
|
onInstallCancelled: resolve,
|
|
onInstallEnded() {
|
|
// onInstallEnded is called right when an add-on has been installed.
|
|
// install() may still be pending, e.g. for updates, and be awaiting
|
|
// the completion of the update, part of which is the removal of the
|
|
// temporary XPI file of the downloaded update. To avoid intermittent
|
|
// test failures due to lingering temporary files, await install().
|
|
resolve(installPromise);
|
|
},
|
|
onInstallPostponed: resolve,
|
|
};
|
|
|
|
install.addListener(listener);
|
|
installPromise = install.install();
|
|
}).then(() => {
|
|
install.removeListener(listener);
|
|
return install;
|
|
});
|
|
},
|
|
|
|
/**
|
|
* 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
|
|
* @param {Object} [installTelemetryInfo = undefined]
|
|
* Optional parameter to set the install telemetry info for the
|
|
* installed addon
|
|
* @returns {Promise}
|
|
* Resolves when the install has completed.
|
|
*/
|
|
async promiseInstallFile(
|
|
file,
|
|
ignoreIncompatible = false,
|
|
installTelemetryInfo
|
|
) {
|
|
let install = await AddonManager.getInstallForFile(
|
|
file,
|
|
null,
|
|
installTelemetryInfo
|
|
);
|
|
if (!install) {
|
|
throw new Error(`No AddonInstall created for ${file.path}`);
|
|
}
|
|
|
|
if (install.state != AddonManager.STATE_DOWNLOADED) {
|
|
throw new Error(
|
|
`Expected file to be downloaded for install of ${file.path}`
|
|
);
|
|
}
|
|
|
|
if (ignoreIncompatible && install.addon.appDisabled) {
|
|
return null;
|
|
}
|
|
|
|
await install.install();
|
|
return 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));
|
|
},
|
|
|
|
/**
|
|
* @property {number} updateReason
|
|
* The default update reason for {@see promiseFindAddonUpdates}
|
|
* calls. May be overwritten by tests which primarily check for
|
|
* updates with a particular reason.
|
|
*/
|
|
updateReason: AddonManager.UPDATE_WHEN_PERIODIC_UPDATE,
|
|
|
|
/**
|
|
* 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.
|
|
* @param {Array} args Additional args to pass to `checkUpdates` after
|
|
* the update reason.
|
|
* @return {Promise<object>} an object containing information about the update.
|
|
*/
|
|
promiseFindAddonUpdates(
|
|
addon,
|
|
reason = AddonTestUtils.updateReason,
|
|
...args
|
|
) {
|
|
// Retrieve the test assertion helper from the testScope
|
|
// (which is `equal` in xpcshell-test and `is` in mochitest)
|
|
let equal = this.testScope.equal || this.testScope.is;
|
|
return new Promise((resolve, reject) => {
|
|
let result = {};
|
|
addon.findUpdates(
|
|
{
|
|
onNoCompatibilityUpdateAvailable(addon2) {
|
|
if ("compatibilityUpdate" in result) {
|
|
throw new Error("Saw multiple compatibility update events");
|
|
}
|
|
equal(addon, addon2, "onNoCompatibilityUpdateAvailable");
|
|
result.compatibilityUpdate = false;
|
|
},
|
|
|
|
onCompatibilityUpdateAvailable(addon2) {
|
|
if ("compatibilityUpdate" in result) {
|
|
throw new Error("Saw multiple compatibility update events");
|
|
}
|
|
equal(addon, addon2, "onCompatibilityUpdateAvailable");
|
|
result.compatibilityUpdate = true;
|
|
},
|
|
|
|
onNoUpdateAvailable(addon2) {
|
|
if ("updateAvailable" in result) {
|
|
throw new Error("Saw multiple update available events");
|
|
}
|
|
equal(addon, addon2, "onNoUpdateAvailable");
|
|
result.updateAvailable = false;
|
|
},
|
|
|
|
onUpdateAvailable(addon2, install) {
|
|
if ("updateAvailable" in result) {
|
|
throw new Error("Saw multiple update available events");
|
|
}
|
|
equal(addon, addon2, "onUpdateAvailable");
|
|
result.updateAvailable = install;
|
|
},
|
|
|
|
onUpdateFinished(addon2, error) {
|
|
equal(addon, addon2, "onUpdateFinished");
|
|
if (error == AddonManager.UPDATE_STATUS_NO_ERROR) {
|
|
resolve(result);
|
|
} else {
|
|
result.error = error;
|
|
reject(result);
|
|
}
|
|
},
|
|
},
|
|
reason,
|
|
...args
|
|
);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* 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
|
|
* an async function, or an ordinary function which returns a 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.
|
|
*/
|
|
async promiseConsoleOutput(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 = await task();
|
|
|
|
Services.console.logStringMessage(DONE);
|
|
await awaitListener;
|
|
|
|
return { messages, result };
|
|
} finally {
|
|
Services.console.unregisterListener(listener);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* An object describing an expected or forbidden console message. Each
|
|
* property in the object corresponds to a property with the same name
|
|
* in a console message. If the value in the pattern object is a
|
|
* regular expression, it must match the value of the corresponding
|
|
* console message property. If it is any other value, it must be
|
|
* strictly equal to the correspondng console message property.
|
|
*
|
|
* @typedef {object} ConsoleMessagePattern
|
|
*/
|
|
|
|
/**
|
|
* Checks the list of messages returned from `promiseConsoleOutput`
|
|
* against the given set of expected messages.
|
|
*
|
|
* This is roughly equivalent to the expected and forbidden message
|
|
* matching functionality of SimpleTest.monitorConsole.
|
|
*
|
|
* @param {Array<object>} messages
|
|
* The array of console messages to match.
|
|
* @param {object} options
|
|
* Options describing how to perform the match.
|
|
* @param {Array<ConsoleMessagePattern>} [options.expected = []]
|
|
* An array of messages which must appear in `messages`. The
|
|
* matching messages in the `messages` array must appear in the
|
|
* same order as the patterns in the `expected` array.
|
|
* @param {Array<ConsoleMessagePattern>} [options.forbidden = []]
|
|
* An array of messages which must not appear in the `messages`
|
|
* array.
|
|
* @param {bool} [options.forbidUnexpected = false]
|
|
* If true, the `messages` array must not contain any messages
|
|
* which are not matched by the given `expected` patterns.
|
|
*/
|
|
checkMessages(
|
|
messages,
|
|
{ expected = [], forbidden = [], forbidUnexpected = false }
|
|
) {
|
|
function msgMatches(msg, expectedMsg) {
|
|
for (let [prop, pattern] of Object.entries(expectedMsg)) {
|
|
if (isRegExp(pattern) && typeof msg[prop] === "string") {
|
|
if (!pattern.test(msg[prop])) {
|
|
return false;
|
|
}
|
|
} else if (msg[prop] !== pattern) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function validateOptionFormat(optionName, optionValue) {
|
|
for (let item of optionValue) {
|
|
if (!item || typeof item !== "object" || isRegExp(item)) {
|
|
throw new Error(
|
|
`Unexpected format in AddonTestUtils.checkMessages "${optionName}" parameter`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
validateOptionFormat("expected", expected);
|
|
validateOptionFormat("forbidden", forbidden);
|
|
|
|
let i = 0;
|
|
for (let msg of messages) {
|
|
if (forbidden.some(pat => msgMatches(msg, pat))) {
|
|
this.testScope.ok(false, `Got forbidden console message: ${msg}`);
|
|
continue;
|
|
}
|
|
|
|
if (i < expected.length && msgMatches(msg, expected[i])) {
|
|
this.info(`Matched expected console message: ${msg}`);
|
|
i++;
|
|
} else if (forbidUnexpected) {
|
|
this.testScope.ok(false, `Got unexpected console message: ${msg}`);
|
|
}
|
|
}
|
|
for (let pat of expected.slice(i)) {
|
|
this.testScope.ok(
|
|
false,
|
|
`Did not get expected console message: ${uneval(pat)}`
|
|
);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Asserts that the expected installTelemetryInfo properties are available
|
|
* on the AddonWrapper or AddonInstall objects.
|
|
*
|
|
* @param {AddonWrapper|AddonInstall} addonOrInstall
|
|
* The addon or addonInstall object to check.
|
|
* @param {Object} expectedInstallInfo
|
|
* The expected installTelemetryInfo properties
|
|
* (every property can be a primitive value or a regular expression).
|
|
* @param {string} [msg]
|
|
* Optional assertion message suffix.
|
|
*/
|
|
checkInstallInfo(addonOrInstall, expectedInstallInfo, msg = undefined) {
|
|
const installInfo = addonOrInstall.installTelemetryInfo;
|
|
const { Assert } = this.testScope;
|
|
|
|
msg = msg ? ` ${msg}` : "";
|
|
|
|
for (const key of Object.keys(expectedInstallInfo)) {
|
|
const actual = installInfo[key];
|
|
let expected = expectedInstallInfo[key];
|
|
|
|
// Assert the property value using a regular expression.
|
|
if (expected && typeof expected.test == "function") {
|
|
Assert.ok(
|
|
expected.test(actual),
|
|
`${key} value "${actual}" has the value expected "${expected}"${msg}`
|
|
);
|
|
} else {
|
|
Assert.deepEqual(
|
|
actual,
|
|
expected,
|
|
`Got the expected value for ${key}${msg}`
|
|
);
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Helper to wait for a webextension to completely start
|
|
*
|
|
* @param {string} [id]
|
|
* An optional extension id to look for.
|
|
*
|
|
* @returns {Promise<Extension>}
|
|
* A promise that resolves with the extension, once it is started.
|
|
*/
|
|
promiseWebExtensionStartup(id) {
|
|
return new Promise(resolve => {
|
|
lazy.Management.on("ready", function listener(event, extension) {
|
|
if (!id || extension.id == id) {
|
|
lazy.Management.off("ready", listener);
|
|
resolve(extension);
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Wait until an extension with a search provider has been loaded.
|
|
* This should be called after the extension has started, but before shutdown.
|
|
*
|
|
* @param {object} extension
|
|
* The return value of ExtensionTestUtils.loadExtension.
|
|
* For browser tests, see mochitest/tests/SimpleTest/ExtensionTestUtils.js
|
|
* For xpcshell tests, see toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs
|
|
* @param {object} [options]
|
|
* Optional options.
|
|
* @param {boolean} [options.expectPending = false]
|
|
* Whether to expect the search provider to still be starting up.
|
|
*/
|
|
async waitForSearchProviderStartup(
|
|
extension,
|
|
{ expectPending = false } = {}
|
|
) {
|
|
// In xpcshell tests, equal/ok are defined in the global scope.
|
|
let { equal, ok } = this.testScope;
|
|
if (!equal || !ok) {
|
|
// In mochitests, these are available via Assert.sys.mjs.
|
|
let { Assert } = this.testScope;
|
|
equal = Assert.equal.bind(Assert);
|
|
ok = Assert.ok.bind(Assert);
|
|
}
|
|
|
|
equal(
|
|
extension.state,
|
|
"running",
|
|
"Search provider extension should be running"
|
|
);
|
|
ok(extension.id, "Extension ID of search provider should be set");
|
|
|
|
// The map of promises from browser/components/extensions/parent/ext-chrome-settings-overrides.js
|
|
let { pendingSearchSetupTasks } = lazy.Management.global;
|
|
let searchStartupPromise = pendingSearchSetupTasks.get(extension.id);
|
|
if (expectPending) {
|
|
ok(
|
|
searchStartupPromise,
|
|
"Search provider registration should be in progress"
|
|
);
|
|
}
|
|
return searchStartupPromise;
|
|
},
|
|
|
|
/**
|
|
* Initializes the URLPreloader, which is required in order to load
|
|
* built_in_addons.json.
|
|
*/
|
|
initializeURLPreloader() {
|
|
lazy.aomStartup.initializeURLPreloader();
|
|
},
|
|
|
|
/**
|
|
* Override chrome URL for specifying allowed built-in add-ons.
|
|
*
|
|
* @param {object} data - An object specifying which add-on IDs are permitted
|
|
* to load, for instance: { "system": ["id1", "..."] }
|
|
*/
|
|
async overrideBuiltIns(data) {
|
|
this.initializeURLPreloader();
|
|
|
|
let file = this.tempDir.clone();
|
|
file.append("override.txt");
|
|
this.tempXPIs.push(file);
|
|
|
|
let manifest = Services.io.newFileURI(file);
|
|
await IOUtils.writeJSON(file.path, data);
|
|
this.overrideEntry = lazy.aomStartup.registerChrome(manifest, [
|
|
[
|
|
"override",
|
|
"chrome://browser/content/built_in_addons.json",
|
|
Services.io.newFileURI(file).spec,
|
|
],
|
|
]);
|
|
},
|
|
|
|
// AMTelemetry events helpers.
|
|
|
|
/**
|
|
* Formerly this function re-routed telemetry events. Now it just ensures
|
|
* that there are no unexamined events after the test file is exiting.
|
|
*/
|
|
hookAMTelemetryEvents() {
|
|
this.testScope.registerCleanupFunction(() => {
|
|
this.testScope.Assert.deepEqual(
|
|
[],
|
|
this.getAMTelemetryEvents(),
|
|
"No unexamined telemetry events after test is finished"
|
|
);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Retrive any AMTelemetry event collected and clears _all_ telemetry events.
|
|
*
|
|
* @returns {Array<Object>}
|
|
* The array of the collected telemetry data.
|
|
*/
|
|
getAMTelemetryEvents() {
|
|
// This duplicates some logic from TelemetryTestUtils.
|
|
let snapshots = Services.telemetry.snapshotEvents(
|
|
Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
|
|
/* clear = */ true
|
|
);
|
|
let events = (snapshots.parent ?? [])
|
|
.filter(entry => entry[1] == "addonsManager")
|
|
.map(entry => ({
|
|
// The callers don't expect the timestamp or the category.
|
|
method: entry[2],
|
|
object: entry[3],
|
|
value: entry[4],
|
|
extra: entry[5],
|
|
}));
|
|
|
|
return events;
|
|
},
|
|
|
|
/**
|
|
* @param {string|string[]} events - The event(s) to retrieve.
|
|
* @param {object} [filter] - key/value pairs to filter events.
|
|
* @returns {object[]} Collected extra objects from events.
|
|
*/
|
|
getAMGleanEvents(events, filter = {}) {
|
|
let result = [];
|
|
for (let event of [].concat(events)) {
|
|
result = result.concat(Glean.addonsManager[event].testGetValue() ?? []);
|
|
}
|
|
|
|
// When combining multiple events, we want them in chronological order.
|
|
result.sort((a, b) => a.timestamp - b.timestamp);
|
|
|
|
result = result.filter(e =>
|
|
Object.keys(filter).every(key => e.extra[key] === filter[key])
|
|
);
|
|
|
|
// We (usually) don't care about install_id, so drop it to ease comparison.
|
|
result.forEach(e => delete e.extra.install_id);
|
|
|
|
// For Glean events, all data is in the extra object.
|
|
return result.map(e => e.extra);
|
|
},
|
|
};
|
|
|
|
for (let [key, val] of Object.entries(AddonTestUtils)) {
|
|
if (typeof val == "function") {
|
|
AddonTestUtils[key] = val.bind(AddonTestUtils);
|
|
}
|
|
}
|
|
|
|
EventEmitter.decorate(AddonTestUtils);
|