fune/toolkit/components/normandy/test/browser/head.js
Mike Cooper 163557534c Bug 1440780 - Add Normandy action for add-on studies r=aswan
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
2018-08-20 20:37:35 +00:00

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);
};