diff options
Diffstat (limited to 'toolkit/components/normandy/test/NormandyTestUtils.jsm')
-rw-r--r-- | toolkit/components/normandy/test/NormandyTestUtils.jsm | 458 |
1 files changed, 458 insertions, 0 deletions
diff --git a/toolkit/components/normandy/test/NormandyTestUtils.jsm b/toolkit/components/normandy/test/NormandyTestUtils.jsm new file mode 100644 index 0000000000..3fa260aa46 --- /dev/null +++ b/toolkit/components/normandy/test/NormandyTestUtils.jsm @@ -0,0 +1,458 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); +const { AddonStudies } = ChromeUtils.import( + "resource://normandy/lib/AddonStudies.jsm" +); +const { NormandyUtils } = ChromeUtils.import( + "resource://normandy/lib/NormandyUtils.jsm" +); +const { RecipeRunner } = ChromeUtils.import( + "resource://normandy/lib/RecipeRunner.jsm" +); +const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); + +const FIXTURE_ADDON_ID = "normandydriver-a@example.com"; +const UUID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + +var EXPORTED_SYMBOLS = ["NormandyTestUtils"]; + +// Factory IDs +let _addonStudyFactoryId = 0; +let _preferenceStudyFactoryId = 0; +let _preferenceRolloutFactoryId = 0; + +let testGlobals = {}; + +const preferenceBranches = { + user: Preferences, + default: new Preferences({ defaultBranch: true }), +}; + +const NormandyTestUtils = { + init({ add_task, Assert } = {}) { + testGlobals.add_task = add_task; + testGlobals.Assert = Assert; + }, + + factories: { + addonStudyFactory(attrs = {}) { + for (const key of ["name", "description"]) { + if (attrs && attrs[key]) { + throw new Error( + `${key} is no longer a valid key for addon studies, please update to v2 study schema` + ); + } + } + + // Generate a slug from userFacingName + let recipeId = _addonStudyFactoryId++; + let { userFacingName = `Test study ${recipeId}`, slug } = attrs; + delete attrs.slug; + if (userFacingName && !slug) { + slug = userFacingName.replace(" ", "-").toLowerCase(); + } + + return Object.assign( + { + recipeId, + slug, + userFacingName: "Test study", + userFacingDescription: "test description", + branch: AddonStudies.NO_BRANCHES_MARKER, + active: true, + addonId: FIXTURE_ADDON_ID, + addonUrl: "http://test/addon.xpi", + addonVersion: "1.0.0", + studyStartDate: new Date(), + studyEndDate: null, + extensionApiId: 1, + extensionHash: + "ade1c14196ec4fe0aa0a6ba40ac433d7c8d1ec985581a8a94d43dc58991b5171", + extensionHashAlgorithm: "sha256", + enrollmentId: NormandyUtils.generateUuid(), + temporaryErrorDeadline: null, + }, + attrs + ); + }, + + branchedAddonStudyFactory(attrs = {}) { + return NormandyTestUtils.factories.addonStudyFactory( + Object.assign( + { + branch: "a", + }, + attrs + ) + ); + }, + + preferenceStudyFactory(attrs = {}) { + const defaultPref = { + "test.study": {}, + }; + const defaultPrefInfo = { + preferenceValue: false, + preferenceType: "boolean", + previousPreferenceValue: null, + preferenceBranchType: "default", + overridden: false, + }; + const preferences = {}; + for (const [prefName, prefInfo] of Object.entries( + attrs.preferences || defaultPref + )) { + preferences[prefName] = { ...defaultPrefInfo, ...prefInfo }; + } + + // Generate a slug from userFacingName + let { + userFacingName = `Test study ${_preferenceStudyFactoryId++}`, + slug, + } = attrs; + delete attrs.slug; + if (userFacingName && !slug) { + slug = userFacingName.replace(" ", "-").toLowerCase(); + } + + return Object.assign( + { + userFacingName, + userFacingDescription: `${userFacingName} description`, + slug, + branch: "control", + expired: false, + lastSeen: new Date().toJSON(), + experimentType: "exp", + enrollmentId: NormandyUtils.generateUuid(), + actionName: "PreferenceExperimentAction", + }, + attrs, + { + preferences, + } + ); + }, + + preferenceRolloutFactory(attrs = {}) { + const defaultPrefInfo = { + preferenceName: "test.rollout.{}", + value: true, + previousValue: false, + }; + const preferences = (attrs.preferences ?? [{}]).map((override, idx) => ({ + ...defaultPrefInfo, + preferenceName: defaultPrefInfo.preferenceName.replace( + "{}", + (idx + 1).toString() + ), + ...override, + })); + + return Object.assign( + { + slug: `test-rollout-${_preferenceRolloutFactoryId++}`, + state: "active", + enrollmentId: NormandyUtils.generateUuid(), + }, + attrs, + { + preferences, + } + ); + }, + }, + + /** + * 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)); + */ + decorate(...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 + * } + * ); + */ + decorate_task(...args) { + return testGlobals.add_task(NormandyTestUtils.decorate(...args)); + }, + + isUuid(s) { + return UUID_REGEX.test(s); + }, + + withMockRecipeCollection(recipes = []) { + return function wrapper(testFunc) { + return async function inner(args) { + let recipeIds = new Set(); + for (const recipe of recipes) { + if (!recipe.id || recipeIds.has(recipe.id)) { + throw new Error( + "To use withMockRecipeCollection each recipe must have a unique ID" + ); + } + recipeIds.add(recipe.id); + } + + let db = await RecipeRunner._remoteSettingsClientForTesting.db; + await db.clear(); + const fakeSig = { signature: "abc" }; + + for (const recipe of recipes) { + await db.create({ + id: `recipe-${recipe.id}`, + recipe, + signature: fakeSig, + }); + } + + // last modified needs to be some positive integer + let lastModified = await db.getLastModified(); + await db.importChanges({}, lastModified + 1); + + const mockRecipeCollection = { + async addRecipes(newRecipes) { + for (const recipe of newRecipes) { + if (!recipe.id || recipeIds.has(recipe)) { + throw new Error( + "To use withMockRecipeCollection each recipe must have a unique ID" + ); + } + } + db = await RecipeRunner._remoteSettingsClientForTesting.db; + for (const recipe of newRecipes) { + recipeIds.add(recipe.id); + await db.create({ + id: `recipe-${recipe.id}`, + recipe, + signature: fakeSig, + }); + } + lastModified = (await db.getLastModified()) || 0; + await db.importChanges({}, lastModified + 1); + }, + }; + + try { + await testFunc({ ...args, mockRecipeCollection }); + } finally { + db = await RecipeRunner._remoteSettingsClientForTesting.db; + await db.clear(); + lastModified = await db.getLastModified(); + await db.importChanges({}, lastModified + 1); + } + }; + }; + }, + + MockPreferences: class { + 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])) { + this.oldValues[branch][name] = preferenceBranches[branch].get( + name, + undefined + ); + } + } + + cleanup() { + for (const [branchName, values] of Object.entries(this.oldValues)) { + const preferenceBranch = preferenceBranches[branchName]; + for (const [name, value] of Object.entries(values)) { + if (value !== undefined) { + preferenceBranch.set(name, value); + } else { + preferenceBranch.reset(name); + } + } + } + } + }, + + withMockPreferences() { + return function(testFunction) { + return async function inner(args) { + const mockPreferences = new NormandyTestUtils.MockPreferences(); + try { + await testFunction({ ...args, mockPreferences }); + } finally { + mockPreferences.cleanup(); + } + }; + }; + }, + + withStub(object, method, { returnValue, as = `${method}Stub` } = {}) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + const stub = sinon.stub(object, method); + if (returnValue) { + stub.returns(returnValue); + } + try { + await testFunction({ ...args, [as]: stub }); + } finally { + stub.restore(); + } + }; + }; + }, + + withSpy(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(); + } + }; + }; + }, + + /** + * Creates an nsIConsoleListener that records all console messages. The + * listener will be provided in the options argument to the test as + * `consoleSpy`, and will have methods to assert that expected messages were + * received. */ + withConsoleSpy() { + return function(testFunction) { + return async function wrappedTestFunction(args) { + const consoleSpy = new TestConsoleListener(); + console.log("Starting to track console messages"); + Services.console.registerListener(consoleSpy); + try { + await testFunction({ ...args, consoleSpy }); + } finally { + Services.console.unregisterListener(consoleSpy); + console.log("Stopped monitoring console messages"); + } + }; + }; + }, +}; + +class TestConsoleListener { + constructor() { + this.messages = []; + } + + /** + * Check that every item listed has been received on the console. Items can + * be strings or regexes. + * + * Strings must be exact matches. Regexes must match according to + * `RegExp::test`, which is to say they are not automatically bound to the + * start or end of the message. If this is desired, include `^` and/or `$` in + * your expression. + * + * @param {String|RegExp} expectedMessages + * @param {String} [assertMessage] A message to include in the assertion message. + * @return {boolean} + */ + assertAtLeast( + expectedMessages, + assertMessage = "Console should contain the expected messages." + ) { + let expectedSet = new Set(expectedMessages); + for (let { message } of this.messages) { + let found = false; + for (let expected of expectedSet) { + if (expected.test && expected.test(message)) { + found = true; + } else if (expected === message) { + found = true; + } + if (found) { + expectedSet.delete(expected); + break; + } + } + } + if (expectedSet.size) { + let remaining = Array.from(expectedSet); + let errorMessageParts = []; + if (assertMessage) { + errorMessageParts.push(assertMessage); + } + errorMessageParts.push(`"${remaining[0]}"`); + if (remaining.length > 1) { + errorMessageParts.push(`and ${remaining.length - 1} more log messages`); + } + errorMessageParts.push("expected in the console but not found."); + testGlobals.Assert.equal( + expectedSet.size, + 0, + errorMessageParts.join(" ") + ); + } else { + testGlobals.Assert.equal(expectedSet.size, 0, assertMessage); + } + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIConsoleListener"]); + } + + // nsIObserver + + /** + * Takes all script error messages that do not have an exception attached, + * and emits a "Log.entryAdded" event. + * + * @param {nsIConsoleMessage} message + * Message originating from the nsIConsoleService. + */ + observe(message) { + this.messages.push(message); + } +} |