forked from mirrors/gecko-dev
Differential Revision: https://phabricator.services.mozilla.com/D22046 --HG-- extra : moz-landing-system : lando
421 lines
13 KiB
JavaScript
421 lines
13 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);
|
|
ChromeUtils.defineModuleGetter(this, "TelemetryTestUtils",
|
|
"resource://testing-common/TelemetryTestUtils.jsm");
|
|
|
|
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.import("resource://testing-common/Sinon.jsm");
|
|
|
|
// 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.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("addons");
|
|
dir.append("normandydriver-1.0.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: {}, extensionDetails: {}};
|
|
|
|
// 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;
|
|
}
|
|
);
|
|
mockApi.fetchExtensionDetails = sinon.stub(NormandyApi, "fetchExtensionDetails").callsFake(
|
|
async extensionId => {
|
|
const details = mockApi.extensionDetails[extensionId];
|
|
if (!details) {
|
|
throw new Error(`Missing extension details for ${extensionId}`);
|
|
}
|
|
return details;
|
|
}
|
|
);
|
|
|
|
try {
|
|
await testFunction(mockApi, ...args);
|
|
} finally {
|
|
mockApi.fetchActions.restore();
|
|
mockApi.fetchRecipes.restore();
|
|
mockApi.fetchImplementation.restore();
|
|
mockApi.fetchExtensionDetails.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();
|
|
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));
|
|
};
|
|
|
|
let _addonStudyFactoryId = 0;
|
|
this.addonStudyFactory = function(attrs) {
|
|
return Object.assign({
|
|
recipeId: _addonStudyFactoryId++,
|
|
name: "Test study",
|
|
description: "fake",
|
|
active: true,
|
|
addonId: "fake@example.com",
|
|
addonUrl: "http://test/addon.xpi",
|
|
addonVersion: "1.0.0",
|
|
studyStartDate: new Date(),
|
|
extensionApiId: 1,
|
|
extensionHash: "ade1c14196ec4fe0aa0a6ba40ac433d7c8d1ec985581a8a94d43dc58991b5171",
|
|
extensionHashAlgorithm: "sha256",
|
|
}, attrs);
|
|
};
|
|
|
|
let _preferenceStudyFactoryId = 0;
|
|
this.preferenceStudyFactory = function(attrs) {
|
|
return Object.assign({
|
|
name: `Test study ${_preferenceStudyFactoryId++}`,
|
|
branch: "control",
|
|
expired: false,
|
|
lastSeen: new Date().toJSON(),
|
|
preferenceName: "test.study",
|
|
preferenceValue: false,
|
|
preferenceType: "boolean",
|
|
previousPreferenceValue: undefined,
|
|
preferenceBranchType: "default",
|
|
experimentType: "exp",
|
|
}, 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) {
|
|
const stub = sinon.spy(TelemetryEvents, "sendEvent");
|
|
stub.assertEvents = (expected) => {
|
|
expected = expected.map(event => ["normandy"].concat(event));
|
|
TelemetryTestUtils.assertEvents(expected, {category: "normandy"}, {clear: false});
|
|
};
|
|
Services.telemetry.clearEvents();
|
|
try {
|
|
await testFunction(...args, stub);
|
|
} finally {
|
|
stub.restore();
|
|
Assert.ok(!stub.threw(), "some telemetry call failed");
|
|
}
|
|
};
|
|
};
|
|
|
|
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;
|
|
},
|
|
};
|