forked from mirrors/gecko-dev
		
	 163557534c
			
		
	
	
		163557534c
		
	
	
	
	
		
			
			This ports the code from the Normandy server github repo to run as a local
action, instead of being fetched from the server.
The original code is here:
c0a8c53707/client/actions/opt-out-study
Differential Revision: https://phabricator.services.mozilla.com/D2973
--HG--
extra : moz-landing-system : lando
		
	
			
		
			
				
	
	
		
			372 lines
		
	
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			372 lines
		
	
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 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/SandboxManager.jsm", this);
 | |
| ChromeUtils.import("resource://normandy/lib/NormandyDriver.jsm", this);
 | |
| ChromeUtils.import("resource://normandy/lib/NormandyApi.jsm", this);
 | |
| ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
 | |
| 
 | |
| // Load mocking/stubbing library, sinon
 | |
| // docs: http://sinonjs.org/docs/
 | |
| /* global sinon */
 | |
| Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js");
 | |
| 
 | |
| // Make sinon assertions fail in a way that mochitest understands
 | |
| sinon.assert.fail = function(message) {
 | |
|   ok(false, message);
 | |
| };
 | |
| 
 | |
| registerCleanupFunction(async function() {
 | |
|   // Cleanup window or the test runner will throw an error
 | |
|   delete window.sinon;
 | |
| });
 | |
| 
 | |
| // Prep Telemetry to receive events from tests
 | |
| TelemetryEvents.init();
 | |
| 
 | |
| this.UUID_REGEX = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/;
 | |
| 
 | |
| this.TEST_XPI_URL = (function() {
 | |
|   const dir = getChromeDir(getResolvedURI(gTestPath));
 | |
|   dir.append("fixtures");
 | |
|   dir.append("normandy.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.withSandboxManager = function(Assert) {
 | |
|   return function wrapper(testFunction) {
 | |
|     return async function wrappedTestFunction(...args) {
 | |
|       const sandboxManager = new SandboxManager();
 | |
|       sandboxManager.addHold("test running");
 | |
| 
 | |
|       await testFunction(...args, sandboxManager);
 | |
| 
 | |
|       sandboxManager.removeHold("test running");
 | |
|       await sandboxManager.isNuked()
 | |
|         .then(() => Assert.ok(true, "sandbox is nuked"))
 | |
|         .catch(e => Assert.ok(false, "sandbox is nuked", e));
 | |
|     };
 | |
|   };
 | |
| };
 | |
| 
 | |
| this.withDriver = function(Assert, testFunction) {
 | |
|   return withSandboxManager(Assert)(async function inner(...args) {
 | |
|     const sandboxManager = args[args.length - 1];
 | |
|     const driver = new NormandyDriver(sandboxManager);
 | |
|     await testFunction(driver, ...args);
 | |
|   });
 | |
| };
 | |
| 
 | |
| this.withMockNormandyApi = function(testFunction) {
 | |
|   return async function inner(...args) {
 | |
|     const mockApi = {actions: [], recipes: [], implementations: {}};
 | |
| 
 | |
|     // Use callsFake instead of resolves so that the current values in mockApi are used.
 | |
|     mockApi.fetchActions = sinon.stub(NormandyApi, "fetchActions").callsFake(async () => mockApi.actions);
 | |
|     mockApi.fetchRecipes = sinon.stub(NormandyApi, "fetchRecipes").callsFake(async () => mockApi.recipes);
 | |
|     mockApi.fetchImplementation = sinon.stub(NormandyApi, "fetchImplementation").callsFake(
 | |
|       async action => {
 | |
|         const impl = mockApi.implementations[action.name];
 | |
|         if (!impl) {
 | |
|           throw new Error(`Missing implementation for ${action.name}`);
 | |
|         }
 | |
|         return impl;
 | |
|       }
 | |
|     );
 | |
| 
 | |
|     try {
 | |
|       await testFunction(mockApi, ...args);
 | |
|     } finally {
 | |
|       mockApi.fetchActions.restore();
 | |
|       mockApi.fetchRecipes.restore();
 | |
|       mockApi.fetchImplementation.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();
 | |
|       }
 | |
|     };
 | |
|   };
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * 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();
 | |
|   funcs.reverse();
 | |
|   for (const func of funcs) {
 | |
|     decorated = func(decorated);
 | |
|   }
 | |
|   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));
 | |
| };
 | |
| 
 | |
| let _studyFactoryId = 0;
 | |
| this.studyFactory = function(attrs) {
 | |
|   return Object.assign({
 | |
|     recipeId: _studyFactoryId++,
 | |
|     name: "Test study",
 | |
|     description: "fake",
 | |
|     active: true,
 | |
|     addonId: "fake@example.com",
 | |
|     addonUrl: "http://test/addon.xpi",
 | |
|     addonVersion: "1.0.0",
 | |
|     studyStartDate: new Date(),
 | |
|   }, attrs);
 | |
| };
 | |
| 
 | |
| 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) {
 | |
| 
 | |
|     /* Checks that calls match the event schema. */
 | |
|     function checkEventMatchesSchema(method, object, value, extra) {
 | |
|       let match = true;
 | |
|       const spec = Array.from(Object.values(TelemetryEvents.eventSchema))
 | |
|         .filter(spec => spec.methods.includes(method))[0];
 | |
| 
 | |
|       if (spec) {
 | |
|         if (!spec.objects.includes(object)) {
 | |
|           match = false;
 | |
|         }
 | |
| 
 | |
|         for (const key of Object.keys(extra)) {
 | |
|           if (!spec.extra_keys.includes(key)) {
 | |
|             match = false;
 | |
|           }
 | |
|         }
 | |
|       } else {
 | |
|         match = false;
 | |
|       }
 | |
| 
 | |
|       ok(match, `sendEvent(${method}, ${object}, ${value}, ${JSON.stringify(extra)}) should match spec`);
 | |
|     }
 | |
| 
 | |
|     const stub = sinon.stub(TelemetryEvents, "sendEvent");
 | |
|     stub.callsFake(checkEventMatchesSchema);
 | |
|     try {
 | |
|       await testFunction(...args, stub);
 | |
|     } finally {
 | |
|       stub.restore();
 | |
|     }
 | |
|   };
 | |
| };
 | |
| 
 | |
| let _recipeId = 1;
 | |
| this.recipeFactory = function(overrides = {}) {
 | |
|   return Object.assign({
 | |
|     id: _recipeId++,
 | |
|     arguments: overrides.arguments || {},
 | |
|   }, overrides);
 | |
| };
 |