diff options
Diffstat (limited to 'toolkit/components/normandy/test/browser/head.js')
-rw-r--r-- | toolkit/components/normandy/test/browser/head.js | 642 |
1 files changed, 642 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..354c38647e --- /dev/null +++ b/toolkit/components/normandy/test/browser/head.js @@ -0,0 +1,642 @@ +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +const { AboutPages } = ChromeUtils.importESModule( + "resource://normandy-content/AboutPages.sys.mjs" +); +const { AddonStudies } = ChromeUtils.importESModule( + "resource://normandy/lib/AddonStudies.sys.mjs" +); +const { NormandyApi } = ChromeUtils.importESModule( + "resource://normandy/lib/NormandyApi.sys.mjs" +); +const { TelemetryEvents } = ChromeUtils.importESModule( + "resource://normandy/lib/TelemetryEvents.sys.mjs" +); +const { ShowHeartbeatAction } = ChromeUtils.importESModule( + "resource://normandy/actions/ShowHeartbeatAction.sys.mjs" +); + +// The name of this module conflicts with the window.Storage +// DOM global - https://developer.mozilla.org/en-US/docs/Web/API/Storage . +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +const { Storage } = ChromeUtils.importESModule( + "resource://normandy/lib/Storage.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", +}); + +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.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +// 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 = {}, + { as = "webExtension" } = {} +) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + const random = Math.random().toString(36).replace(/0./, "").substr(-3); + let addonId = `normandydriver_${random}@example.com`; + if ("id" in manifestOverrides) { + addonId = 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", + browser_specific_settings: { + gecko: { id: addonId }, + }, + }, + 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, [as]: { addonId, addonFile } }); + } finally { + AddonTestUtils.cleanupTempXPIs(); + } + }; + }; +}; + +this.withCorruptedWebExtension = function (options) { + // This should be an invalid manifest version, so that installing this add-on fails. + return this.withWebExtension({ manifest_version: -1 }, options); +}; + +this.withInstalledWebExtension = function ( + manifestOverrides = {}, + { expectUninstall = false, as = "installedWebExtension" } = {} +) { + return function wrapper(testFunction) { + return decorate( + withWebExtension(manifestOverrides, { as }), + async function wrappedTestFunction(args) { + const { addonId, addonFile } = args[as]; + const startupPromise = + AddonTestUtils.promiseWebExtensionStartup(addonId); + const addonInstall = await AddonManager.getInstallForFile( + addonFile, + "application/x-xpinstall" + ); + await addonInstall.install(); + await startupPromise; + + try { + await testFunction(args); + } finally { + const addonToUninstall = await AddonManager.getAddonByID(addonId); + if (addonToUninstall) { + await addonToUninstall.uninstall(); + } else { + ok( + expectUninstall, + "Add-on should not be unexpectedly uninstalled during test" + ); + } + } + } + ); + }; +}; + +this.withMockNormandyApi = function () { + return function (testFunction) { + return async function inner(args) { + const mockNormandyApi = { + actions: [], + recipes: [], + implementations: {}, + extensionDetails: {}, + }; + + // Use callsFake instead of resolves so that the current values in mockApi are used. + mockNormandyApi.fetchExtensionDetails = sinon + .stub(NormandyApi, "fetchExtensionDetails") + .callsFake(async extensionId => { + const details = mockNormandyApi.extensionDetails[extensionId]; + if (!details) { + throw new Error(`Missing extension details for ${extensionId}`); + } + return details; + }); + + try { + await testFunction({ ...args, mockNormandyApi }); + } finally { + mockNormandyApi.fetchExtensionDetails.restore(); + } + }; + }; +}; + +const preferenceBranches = { + user: Preferences, + default: new Preferences({ defaultBranch: true }), +}; + +this.withMockPreferences = function () { + return function (testFunction) { + return async function inner(args) { + const mockPreferences = new MockPreferences(); + try { + await testFunction({ ...args, mockPreferences }); + } finally { + mockPreferences.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 (branch !== "user" && branch !== "default") { + throw new Error(`Unexpected branch ${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 () { + return 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 ( + object, + method, + { returnValue, as = `${method}Stub` } = {} +) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + const stub = sinon.stub(object, method); + stub.returnValue = returnValue; + try { + await testFunction({ ...args, [as]: stub }); + } finally { + stub.restore(); + } + }; + }; +}; + +this.withSpy = function (object, method, { as = `${method}Spy` } = {}) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + const spy = sinon.spy(object, method); + try { + await testFunction({ ...args, [as]: spy }); + } finally { + spy.restore(); + } + }; + }; +}; + +this.studyEndObserved = function (recipeId) { + return TestUtils.topicObserved( + "shield-study-ended", + (subject, endedRecipeId) => Number.parseInt(endedRecipeId) === recipeId + ); +}; + +this.withSendEventSpy = function () { + return function (testFunction) { + return async function wrappedTestFunction(args) { + const sendEventSpy = sinon.spy(TelemetryEvents, "sendEvent"); + sendEventSpy.assertEvents = expected => { + expected = expected.map(event => ["normandy"].concat(event)); + TelemetryTestUtils.assertEvents( + expected, + { category: "normandy" }, + { clear: false } + ); + }; + Services.telemetry.clearEvents(); + try { + await testFunction({ ...args, sendEventSpy }); + } finally { + sendEventSpy.restore(); + Assert.ok(!sendEventSpy.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 = {}, + { as = "installedWebExtensionSafe" } = {} +) { + return testFunction => { + return async function wrappedTestFunction(args) { + const decorated = withInstalledWebExtension(manifestOverrides, { + expectUninstall: true, + as, + })(async ({ [as]: { addonId, addonFile } }) => { + try { + await testFunction({ ...args, [as]: { addonId, addonFile } }); + } finally { + let addon = await AddonManager.getAddonByID(addonId); + if (addon) { + await safeUninstallAddon(addon); + addon = await AddonManager.getAddonByID(addonId); + ok(!addon, "add-on should be uninstalled"); + } + } + }); + await decorated(); + }; + }; +}; + +/** + * Test decorator to provide a web extension installed from a URL. + */ +this.withInstalledWebExtensionFromURL = function ( + url, + { as = "installedWebExtension" } = {} +) { + 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, [as]: { 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 () { + return 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" + ); + } + }; + }; +}; + +class MockHeartbeat { + constructor() { + this.eventEmitter = new MockEventEmitter(); + } +} + +class MockEventEmitter { + constructor() { + this.once = sinon.stub(); + } +} + +function withStubbedHeartbeat() { + return function (testFunction) { + return async function wrappedTestFunction(args) { + const heartbeatInstanceStub = new MockHeartbeat(); + const heartbeatClassStub = sinon.stub(); + heartbeatClassStub.returns(heartbeatInstanceStub); + ShowHeartbeatAction.overrideHeartbeatForTests(heartbeatClassStub); + + try { + await testFunction({ + ...args, + heartbeatClassStub, + heartbeatInstanceStub, + }); + } finally { + ShowHeartbeatAction.overrideHeartbeatForTests(); + } + }; + }; +} + +function withClearStorage() { + return function (testFunction) { + return async function wrappedTestFunction(args) { + Storage.clearAllStorage(); + try { + await testFunction(args); + } finally { + Storage.clearAllStorage(); + } + }; + }; +} |