summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/test/browser/head.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/normandy/test/browser/head.js')
-rw-r--r--toolkit/components/normandy/test/browser/head.js548
1 files changed, 548 insertions, 0 deletions
diff --git a/toolkit/components/normandy/test/browser/head.js b/toolkit/components/normandy/test/browser/head.js
new file mode 100644
index 0000000000..90fe657003
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/head.js
@@ -0,0 +1,548 @@
+ChromeUtils.import("resource://gre/modules/Preferences.jsm", this);
+ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm", this);
+ChromeUtils.import("resource://testing-common/TestUtils.jsm", this);
+ChromeUtils.import("resource://normandy-content/AboutPages.jsm", this);
+ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
+ChromeUtils.import("resource://normandy/lib/NormandyApi.jsm", this);
+ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
+ChromeUtils.defineModuleGetter(
+ this,
+ "TelemetryTestUtils",
+ "resource://testing-common/TelemetryTestUtils.jsm"
+);
+
+const CryptoHash = Components.Constructor(
+ "@mozilla.org/security/hash;1",
+ "nsICryptoHash",
+ "initWithString"
+);
+const FileInputStream = Components.Constructor(
+ "@mozilla.org/network/file-input-stream;1",
+ "nsIFileInputStream",
+ "init"
+);
+
+const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
+
+// Make sinon assertions fail in a way that mochitest understands
+sinon.assert.fail = function(message) {
+ ok(false, message);
+};
+
+// Prep Telemetry to receive events from tests
+TelemetryEvents.init();
+
+this.TEST_XPI_URL = (function() {
+ const dir = getChromeDir(getResolvedURI(gTestPath));
+ dir.append("addons");
+ dir.append("normandydriver-a-1.0.xpi");
+ return Services.io.newFileURI(dir).spec;
+})();
+
+this.withWebExtension = function(manifestOverrides = {}) {
+ return function wrapper(testFunction) {
+ return async function wrappedTestFunction(...args) {
+ const random = Math.random()
+ .toString(36)
+ .replace(/0./, "")
+ .substr(-3);
+ let id = `normandydriver_${random}@example.com`;
+ if ("id" in manifestOverrides) {
+ id = manifestOverrides.id;
+ delete manifestOverrides.id;
+ }
+
+ const manifest = Object.assign(
+ {
+ manifest_version: 2,
+ name: "normandy_fixture",
+ version: "1.0",
+ description: "Dummy test fixture that's a webextension",
+ applications: {
+ gecko: { id },
+ },
+ },
+ manifestOverrides
+ );
+
+ const addonFile = AddonTestUtils.createTempWebExtensionFile({ manifest });
+
+ // Workaround: Add-on files are cached by URL, and
+ // createTempWebExtensionFile re-uses filenames if the previous file has
+ // been deleted. So we need to flush the cache to avoid it.
+ Services.obs.notifyObservers(addonFile, "flush-cache-entry");
+
+ try {
+ await testFunction(...args, [id, addonFile]);
+ } finally {
+ AddonTestUtils.cleanupTempXPIs();
+ }
+ };
+ };
+};
+
+this.withCorruptedWebExtension = function() {
+ // This should be an invalid manifest version, so that installing this add-on fails.
+ return this.withWebExtension({ manifest_version: -1 });
+};
+
+this.withInstalledWebExtension = function(
+ manifestOverrides = {},
+ expectUninstall = false
+) {
+ return function wrapper(testFunction) {
+ return decorate(
+ withWebExtension(manifestOverrides),
+ async function wrappedTestFunction(...args) {
+ const [id, file] = args[args.length - 1];
+ const startupPromise = AddonTestUtils.promiseWebExtensionStartup(id);
+ const addonInstall = await AddonManager.getInstallForFile(
+ file,
+ "application/x-xpinstall"
+ );
+ await addonInstall.install();
+ await startupPromise;
+
+ try {
+ await testFunction(...args);
+ } finally {
+ const addonToUninstall = await AddonManager.getAddonByID(id);
+ if (addonToUninstall) {
+ await addonToUninstall.uninstall();
+ } else {
+ ok(
+ expectUninstall,
+ "Add-on should not be unexpectedly uninstalled during test"
+ );
+ }
+ }
+ }
+ );
+ };
+};
+
+this.withMockNormandyApi = function(testFunction) {
+ return async function inner(...args) {
+ const mockApi = {
+ actions: [],
+ recipes: [],
+ implementations: {},
+ extensionDetails: {},
+ };
+
+ // Use callsFake instead of resolves so that the current values in mockApi are used.
+ mockApi.fetchExtensionDetails = sinon
+ .stub(NormandyApi, "fetchExtensionDetails")
+ .callsFake(async extensionId => {
+ const details = mockApi.extensionDetails[extensionId];
+ if (!details) {
+ throw new Error(`Missing extension details for ${extensionId}`);
+ }
+ return details;
+ });
+
+ try {
+ await testFunction(...args, mockApi);
+ } finally {
+ mockApi.fetchExtensionDetails.restore();
+ }
+ };
+};
+
+const preferenceBranches = {
+ user: Preferences,
+ default: new Preferences({ defaultBranch: true }),
+};
+
+this.withMockPreferences = function(testFunction) {
+ return async function inner(...args) {
+ const prefManager = new MockPreferences();
+ try {
+ await testFunction(...args, prefManager);
+ } finally {
+ prefManager.cleanup();
+ }
+ };
+};
+
+class MockPreferences {
+ constructor() {
+ this.oldValues = { user: {}, default: {} };
+ }
+
+ set(name, value, branch = "user") {
+ this.preserve(name, branch);
+ preferenceBranches[branch].set(name, value);
+ }
+
+ preserve(name, branch) {
+ if (!(name in this.oldValues[branch])) {
+ const preferenceBranch = preferenceBranches[branch];
+ let oldValue;
+ let existed;
+ try {
+ oldValue = preferenceBranch.get(name);
+ existed = preferenceBranch.has(name);
+ } catch (e) {
+ oldValue = null;
+ existed = false;
+ }
+ this.oldValues[branch][name] = { oldValue, existed };
+ }
+ }
+
+ cleanup() {
+ for (const [branchName, values] of Object.entries(this.oldValues)) {
+ const preferenceBranch = preferenceBranches[branchName];
+ for (const [name, { oldValue, existed }] of Object.entries(values)) {
+ const before = preferenceBranch.get(name);
+
+ if (before === oldValue) {
+ continue;
+ }
+
+ if (existed) {
+ preferenceBranch.set(name, oldValue);
+ } else if (branchName === "default") {
+ Services.prefs.getDefaultBranch(name).deleteBranch("");
+ } else {
+ preferenceBranch.reset(name);
+ }
+
+ const after = preferenceBranch.get(name);
+ if (before === after && before !== undefined) {
+ throw new Error(
+ `Couldn't reset pref "${name}" to "${oldValue}" on "${branchName}" branch ` +
+ `(value stayed "${before}", did ${existed ? "" : "not "}exist)`
+ );
+ }
+ }
+ }
+ }
+}
+
+this.withPrefEnv = function(inPrefs) {
+ return function wrapper(testFunc) {
+ return async function inner(...args) {
+ await SpecialPowers.pushPrefEnv(inPrefs);
+ try {
+ await testFunc(...args);
+ } finally {
+ await SpecialPowers.popPrefEnv();
+ }
+ };
+ };
+};
+
+this.withStudiesEnabled = function(testFunc) {
+ return async function inner(...args) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["app.shield.optoutstudies.enabled", true]],
+ });
+ try {
+ await testFunc(...args);
+ } finally {
+ await SpecialPowers.popPrefEnv();
+ }
+ };
+};
+
+/**
+ * Combine a list of functions right to left. The rightmost function is passed
+ * to the preceding function as the argument; the result of this is passed to
+ * the next function until all are exhausted. For example, this:
+ *
+ * decorate(func1, func2, func3);
+ *
+ * is equivalent to this:
+ *
+ * func1(func2(func3));
+ */
+this.decorate = function(...args) {
+ const funcs = Array.from(args);
+ let decorated = funcs.pop();
+ const origName = decorated.name;
+ funcs.reverse();
+ for (const func of funcs) {
+ decorated = func(decorated);
+ }
+ Object.defineProperty(decorated, "name", { value: origName });
+ return decorated;
+};
+
+/**
+ * Wrapper around add_task for declaring tests that use several with-style
+ * wrappers. The last argument should be your test function; all other arguments
+ * should be functions that accept a single test function argument.
+ *
+ * The arguments are combined using decorate and passed to add_task as a single
+ * test function.
+ *
+ * @param {[Function]} args
+ * @example
+ * decorate_task(
+ * withMockPreferences,
+ * withMockNormandyApi,
+ * async function myTest(mockPreferences, mockApi) {
+ * // Do a test
+ * }
+ * );
+ */
+this.decorate_task = function(...args) {
+ return add_task(decorate(...args));
+};
+
+this.withStub = function(...stubArgs) {
+ return function wrapper(testFunction) {
+ return async function wrappedTestFunction(...args) {
+ const stub = sinon.stub(...stubArgs);
+ try {
+ await testFunction(...args, stub);
+ } finally {
+ stub.restore();
+ }
+ };
+ };
+};
+
+this.withSpy = function(...spyArgs) {
+ return function wrapper(testFunction) {
+ return async function wrappedTestFunction(...args) {
+ const spy = sinon.spy(...spyArgs);
+ try {
+ await testFunction(...args, spy);
+ } finally {
+ spy.restore();
+ }
+ };
+ };
+};
+
+this.studyEndObserved = function(recipeId) {
+ return TestUtils.topicObserved(
+ "shield-study-ended",
+ (subject, endedRecipeId) => Number.parseInt(endedRecipeId) === recipeId
+ );
+};
+
+this.withSendEventStub = function(testFunction) {
+ return async function wrappedTestFunction(...args) {
+ const stub = sinon.spy(TelemetryEvents, "sendEvent");
+ stub.assertEvents = expected => {
+ expected = expected.map(event => ["normandy"].concat(event));
+ TelemetryTestUtils.assertEvents(
+ expected,
+ { category: "normandy" },
+ { clear: false }
+ );
+ };
+ Services.telemetry.clearEvents();
+ try {
+ await testFunction(...args, stub);
+ } finally {
+ stub.restore();
+ Assert.ok(!stub.threw(), "Telemetry events should not fail");
+ }
+ };
+};
+
+let _recipeId = 1;
+this.recipeFactory = function(overrides = {}) {
+ return Object.assign(
+ {
+ id: _recipeId++,
+ arguments: overrides.arguments || {},
+ },
+ overrides
+ );
+};
+
+function mockLogger() {
+ const logStub = sinon.stub();
+ logStub.fatal = sinon.stub();
+ logStub.error = sinon.stub();
+ logStub.warn = sinon.stub();
+ logStub.info = sinon.stub();
+ logStub.config = sinon.stub();
+ logStub.debug = sinon.stub();
+ logStub.trace = sinon.stub();
+ return logStub;
+}
+
+this.CryptoUtils = {
+ _getHashStringForCrypto(aCrypto) {
+ // return the two-digit hexadecimal code for a byte
+ let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2);
+
+ // convert the binary hash data to a hex string.
+ let binary = aCrypto.finish(false);
+ let hash = Array.from(binary, c => toHexString(c.charCodeAt(0)));
+ return hash.join("").toLowerCase();
+ },
+
+ /**
+ * Get the computed hash for a given file
+ * @param {nsIFile} file The file to be hashed
+ * @param {string} [algorithm] The hashing algorithm to use
+ */
+ getFileHash(file, algorithm = "sha256") {
+ const crypto = CryptoHash(algorithm);
+ const fis = new FileInputStream(file, -1, -1, false);
+ crypto.updateFromStream(fis, file.fileSize);
+ const hash = this._getHashStringForCrypto(crypto);
+ fis.close();
+ return hash;
+ },
+};
+
+const FIXTURE_ADDON_ID = "normandydriver-a@example.com";
+const FIXTURE_ADDON_BASE_URL =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+ ) + "/addons/";
+
+const FIXTURE_ADDONS = [
+ "normandydriver-a-1.0",
+ "normandydriver-b-1.0",
+ "normandydriver-a-2.0",
+];
+
+// Generate fixture add-on details
+this.FIXTURE_ADDON_DETAILS = {};
+FIXTURE_ADDONS.forEach(addon => {
+ const filename = `${addon}.xpi`;
+ const dir = getChromeDir(getResolvedURI(gTestPath));
+ dir.append("addons");
+ dir.append(filename);
+ const xpiFile = Services.io.newFileURI(dir).QueryInterface(Ci.nsIFileURL)
+ .file;
+
+ FIXTURE_ADDON_DETAILS[addon] = {
+ url: `${FIXTURE_ADDON_BASE_URL}${filename}`,
+ hash: CryptoUtils.getFileHash(xpiFile, "sha256"),
+ };
+});
+
+this.extensionDetailsFactory = function(overrides = {}) {
+ return Object.assign(
+ {
+ id: 1,
+ name: "Normandy Fixture",
+ xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+ extension_id: FIXTURE_ADDON_ID,
+ version: "1.0",
+ hash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
+ hash_algorithm: "sha256",
+ },
+ overrides
+ );
+};
+
+/**
+ * Utility function to uninstall addons safely. Preventing the issue mentioned
+ * in bug 1485569.
+ *
+ * addon.uninstall is async, but it also triggers the AddonStudies onUninstall
+ * listener, which is not awaited. Wrap it here and trigger a promise once it's
+ * done so we can wait until AddonStudies cleanup is finished.
+ */
+this.safeUninstallAddon = async function(addon) {
+ const activeStudies = (await AddonStudies.getAll()).filter(
+ study => study.active
+ );
+ const matchingStudy = activeStudies.find(study => study.addonId === addon.id);
+
+ let studyEndedPromise;
+ if (matchingStudy) {
+ studyEndedPromise = TestUtils.topicObserved(
+ "shield-study-ended",
+ (subject, message) => {
+ return message === `${matchingStudy.recipeId}`;
+ }
+ );
+ }
+
+ const addonUninstallPromise = addon.uninstall();
+
+ return Promise.all([studyEndedPromise, addonUninstallPromise]);
+};
+
+/**
+ * Test decorator that is a modified version of the withInstalledWebExtension
+ * decorator that safely uninstalls the created addon.
+ */
+this.withInstalledWebExtensionSafe = function(manifestOverrides = {}) {
+ return testFunction => {
+ return async function wrappedTestFunction(...args) {
+ const decorated = withInstalledWebExtension(
+ manifestOverrides,
+ true
+ )(async ([id, file]) => {
+ try {
+ await testFunction(...args, [id, file]);
+ } finally {
+ let addon = await AddonManager.getAddonByID(id);
+ if (addon) {
+ await safeUninstallAddon(addon);
+ addon = await AddonManager.getAddonByID(id);
+ ok(!addon, "add-on should be uninstalled");
+ }
+ }
+ });
+ await decorated();
+ };
+ };
+};
+
+/**
+ * Test decorator to provide a web extension installed from a URL.
+ */
+this.withInstalledWebExtensionFromURL = function(url) {
+ return function wrapper(testFunction) {
+ return async function wrappedTestFunction(...args) {
+ let startupPromise;
+ let addonId;
+
+ const install = await AddonManager.getInstallForURL(url);
+ const listener = {
+ onInstallStarted(cbInstall) {
+ addonId = cbInstall.addon.id;
+ startupPromise = AddonTestUtils.promiseWebExtensionStartup(addonId);
+ },
+ };
+ install.addListener(listener);
+
+ await install.install();
+ await startupPromise;
+
+ try {
+ await testFunction(...args, [addonId, url]);
+ } finally {
+ const addonToUninstall = await AddonManager.getAddonByID(addonId);
+ await safeUninstallAddon(addonToUninstall);
+ }
+ };
+ };
+};
+
+/**
+ * Test decorator that checks that the test cleans up all add-ons installed
+ * during the test. Likely needs to be the first decorator used.
+ */
+this.ensureAddonCleanup = function(testFunction) {
+ return async function wrappedTestFunction(...args) {
+ const beforeAddons = new Set(await AddonManager.getAllAddons());
+
+ try {
+ await testFunction(...args);
+ } finally {
+ const afterAddons = new Set(await AddonManager.getAllAddons());
+ Assert.deepEqual(
+ beforeAddons,
+ afterAddons,
+ "The add-ons should be same before and after the test"
+ );
+ }
+ };
+};