forked from mirrors/gecko-dev
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);
|
|
};
|