639 lines
18 KiB
JavaScript
639 lines
18 KiB
JavaScript
const { Preferences } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/Preferences.sys.mjs"
|
|
);
|
|
const { AddonTestUtils } = ChromeUtils.importESModule(
|
|
"resource://testing-common/AddonTestUtils.sys.mjs"
|
|
);
|
|
const { AboutPages } = ChromeUtils.importESModule(
|
|
"resource://normandy-content/AboutPages.sys.mjs"
|
|
);
|
|
const { AddonStudies } = ChromeUtils.importESModule(
|
|
"resource://normandy/lib/AddonStudies.sys.mjs"
|
|
);
|
|
const { NormandyApi } = ChromeUtils.importESModule(
|
|
"resource://normandy/lib/NormandyApi.sys.mjs"
|
|
);
|
|
const { TelemetryEvents } = ChromeUtils.importESModule(
|
|
"resource://normandy/lib/TelemetryEvents.sys.mjs"
|
|
);
|
|
const { ShowHeartbeatAction } = ChromeUtils.importESModule(
|
|
"resource://normandy/actions/ShowHeartbeatAction.sys.mjs"
|
|
);
|
|
|
|
// The name of this module conflicts with the window.Storage
|
|
// DOM global - https://developer.mozilla.org/en-US/docs/Web/API/Storage .
|
|
// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix
|
|
const { Storage } = ChromeUtils.importESModule(
|
|
"resource://normandy/lib/Storage.sys.mjs"
|
|
);
|
|
|
|
ChromeUtils.defineESModuleGetters(this, {
|
|
TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
|
|
});
|
|
|
|
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.importESModule(
|
|
"resource://testing-common/Sinon.sys.mjs"
|
|
);
|
|
|
|
// Make sinon assertions fail in a way that mochitest understands
|
|
sinon.assert.fail = function (message) {
|
|
ok(false, message);
|
|
};
|
|
|
|
this.TEST_XPI_URL = (function () {
|
|
const dir = getChromeDir(getResolvedURI(gTestPath));
|
|
dir.append("addons");
|
|
dir.append("normandydriver-a-1.0.xpi");
|
|
return Services.io.newFileURI(dir).spec;
|
|
})();
|
|
|
|
this.withWebExtension = function (
|
|
manifestOverrides = {},
|
|
{ as = "webExtension" } = {}
|
|
) {
|
|
return function wrapper(testFunction) {
|
|
return async function wrappedTestFunction(args) {
|
|
const random = Math.random().toString(36).replace(/0./, "").substr(-3);
|
|
let addonId = `normandydriver_${random}@example.com`;
|
|
if ("id" in manifestOverrides) {
|
|
addonId = 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",
|
|
browser_specific_settings: {
|
|
gecko: { id: addonId },
|
|
},
|
|
},
|
|
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, [as]: { addonId, addonFile } });
|
|
} finally {
|
|
AddonTestUtils.cleanupTempXPIs();
|
|
}
|
|
};
|
|
};
|
|
};
|
|
|
|
this.withCorruptedWebExtension = function (options) {
|
|
// This should be an invalid manifest version, so that installing this add-on fails.
|
|
return this.withWebExtension({ manifest_version: -1 }, options);
|
|
};
|
|
|
|
this.withInstalledWebExtension = function (
|
|
manifestOverrides = {},
|
|
{ expectUninstall = false, as = "installedWebExtension" } = {}
|
|
) {
|
|
return function wrapper(testFunction) {
|
|
return decorate(
|
|
withWebExtension(manifestOverrides, { as }),
|
|
async function wrappedTestFunction(args) {
|
|
const { addonId, addonFile } = args[as];
|
|
const startupPromise =
|
|
AddonTestUtils.promiseWebExtensionStartup(addonId);
|
|
const addonInstall = await AddonManager.getInstallForFile(
|
|
addonFile,
|
|
"application/x-xpinstall"
|
|
);
|
|
await addonInstall.install();
|
|
await startupPromise;
|
|
|
|
try {
|
|
await testFunction(args);
|
|
} finally {
|
|
const addonToUninstall = await AddonManager.getAddonByID(addonId);
|
|
if (addonToUninstall) {
|
|
await addonToUninstall.uninstall();
|
|
} else {
|
|
ok(
|
|
expectUninstall,
|
|
"Add-on should not be unexpectedly uninstalled during test"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
);
|
|
};
|
|
};
|
|
|
|
this.withMockNormandyApi = function () {
|
|
return function (testFunction) {
|
|
return async function inner(args) {
|
|
const mockNormandyApi = {
|
|
actions: [],
|
|
recipes: [],
|
|
implementations: {},
|
|
extensionDetails: {},
|
|
};
|
|
|
|
// Use callsFake instead of resolves so that the current values in mockApi are used.
|
|
mockNormandyApi.fetchExtensionDetails = sinon
|
|
.stub(NormandyApi, "fetchExtensionDetails")
|
|
.callsFake(async extensionId => {
|
|
const details = mockNormandyApi.extensionDetails[extensionId];
|
|
if (!details) {
|
|
throw new Error(`Missing extension details for ${extensionId}`);
|
|
}
|
|
return details;
|
|
});
|
|
|
|
try {
|
|
await testFunction({ ...args, mockNormandyApi });
|
|
} finally {
|
|
mockNormandyApi.fetchExtensionDetails.restore();
|
|
}
|
|
};
|
|
};
|
|
};
|
|
|
|
const preferenceBranches = {
|
|
user: Preferences,
|
|
default: new Preferences({ defaultBranch: true }),
|
|
};
|
|
|
|
this.withMockPreferences = function () {
|
|
return function (testFunction) {
|
|
return async function inner(args) {
|
|
const mockPreferences = new MockPreferences();
|
|
try {
|
|
await testFunction({ ...args, mockPreferences });
|
|
} finally {
|
|
mockPreferences.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 (branch !== "user" && branch !== "default") {
|
|
throw new Error(`Unexpected branch ${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();
|
|
}
|
|
};
|
|
};
|
|
};
|
|
|
|
this.withStudiesEnabled = function () {
|
|
return function (testFunc) {
|
|
return async function inner(args) {
|
|
await SpecialPowers.pushPrefEnv({
|
|
set: [["app.shield.optoutstudies.enabled", true]],
|
|
});
|
|
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));
|
|
};
|
|
|
|
this.withStub = function (
|
|
object,
|
|
method,
|
|
{ returnValue, as = `${method}Stub` } = {}
|
|
) {
|
|
return function wrapper(testFunction) {
|
|
return async function wrappedTestFunction(args) {
|
|
const stub = sinon.stub(object, method);
|
|
stub.returnValue = returnValue;
|
|
try {
|
|
await testFunction({ ...args, [as]: stub });
|
|
} finally {
|
|
stub.restore();
|
|
}
|
|
};
|
|
};
|
|
};
|
|
|
|
this.withSpy = function (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();
|
|
}
|
|
};
|
|
};
|
|
};
|
|
|
|
this.studyEndObserved = function (recipeId) {
|
|
return TestUtils.topicObserved(
|
|
"shield-study-ended",
|
|
(subject, endedRecipeId) => Number.parseInt(endedRecipeId) === recipeId
|
|
);
|
|
};
|
|
|
|
this.withSendEventSpy = function () {
|
|
return function (testFunction) {
|
|
return async function wrappedTestFunction(args) {
|
|
const sendEventSpy = sinon.spy(TelemetryEvents, "sendEvent");
|
|
sendEventSpy.assertEvents = expected => {
|
|
expected = expected.map(event => ["normandy"].concat(event));
|
|
TelemetryTestUtils.assertEvents(
|
|
expected,
|
|
{ category: "normandy" },
|
|
{ clear: false }
|
|
);
|
|
};
|
|
Services.telemetry.clearEvents();
|
|
try {
|
|
await testFunction({ ...args, sendEventSpy });
|
|
} finally {
|
|
sendEventSpy.restore();
|
|
Assert.ok(!sendEventSpy.threw(), "Telemetry events should not fail");
|
|
}
|
|
};
|
|
};
|
|
};
|
|
|
|
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;
|
|
},
|
|
};
|
|
|
|
const FIXTURE_ADDON_ID = "normandydriver-a@example.com";
|
|
const FIXTURE_ADDON_BASE_URL =
|
|
getRootDirectory(gTestPath).replace(
|
|
"chrome://mochitests/content",
|
|
"http://example.com"
|
|
) + "/addons/";
|
|
|
|
const FIXTURE_ADDONS = [
|
|
"normandydriver-a-1.0",
|
|
"normandydriver-b-1.0",
|
|
"normandydriver-a-2.0",
|
|
];
|
|
|
|
// Generate fixture add-on details
|
|
this.FIXTURE_ADDON_DETAILS = {};
|
|
FIXTURE_ADDONS.forEach(addon => {
|
|
const filename = `${addon}.xpi`;
|
|
const dir = getChromeDir(getResolvedURI(gTestPath));
|
|
dir.append("addons");
|
|
dir.append(filename);
|
|
const xpiFile = Services.io
|
|
.newFileURI(dir)
|
|
.QueryInterface(Ci.nsIFileURL).file;
|
|
|
|
FIXTURE_ADDON_DETAILS[addon] = {
|
|
url: `${FIXTURE_ADDON_BASE_URL}${filename}`,
|
|
hash: CryptoUtils.getFileHash(xpiFile, "sha256"),
|
|
};
|
|
});
|
|
|
|
this.extensionDetailsFactory = function (overrides = {}) {
|
|
return Object.assign(
|
|
{
|
|
id: 1,
|
|
name: "Normandy Fixture",
|
|
xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
|
|
extension_id: FIXTURE_ADDON_ID,
|
|
version: "1.0",
|
|
hash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
|
|
hash_algorithm: "sha256",
|
|
},
|
|
overrides
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Utility function to uninstall addons safely. Preventing the issue mentioned
|
|
* in bug 1485569.
|
|
*
|
|
* addon.uninstall is async, but it also triggers the AddonStudies onUninstall
|
|
* listener, which is not awaited. Wrap it here and trigger a promise once it's
|
|
* done so we can wait until AddonStudies cleanup is finished.
|
|
*/
|
|
this.safeUninstallAddon = async function (addon) {
|
|
const activeStudies = (await AddonStudies.getAll()).filter(
|
|
study => study.active
|
|
);
|
|
const matchingStudy = activeStudies.find(study => study.addonId === addon.id);
|
|
|
|
let studyEndedPromise;
|
|
if (matchingStudy) {
|
|
studyEndedPromise = TestUtils.topicObserved(
|
|
"shield-study-ended",
|
|
(subject, message) => {
|
|
return message === `${matchingStudy.recipeId}`;
|
|
}
|
|
);
|
|
}
|
|
|
|
const addonUninstallPromise = addon.uninstall();
|
|
|
|
return Promise.all([studyEndedPromise, addonUninstallPromise]);
|
|
};
|
|
|
|
/**
|
|
* Test decorator that is a modified version of the withInstalledWebExtension
|
|
* decorator that safely uninstalls the created addon.
|
|
*/
|
|
this.withInstalledWebExtensionSafe = function (
|
|
manifestOverrides = {},
|
|
{ as = "installedWebExtensionSafe" } = {}
|
|
) {
|
|
return testFunction => {
|
|
return async function wrappedTestFunction(args) {
|
|
const decorated = withInstalledWebExtension(manifestOverrides, {
|
|
expectUninstall: true,
|
|
as,
|
|
})(async ({ [as]: { addonId, addonFile } }) => {
|
|
try {
|
|
await testFunction({ ...args, [as]: { addonId, addonFile } });
|
|
} finally {
|
|
let addon = await AddonManager.getAddonByID(addonId);
|
|
if (addon) {
|
|
await safeUninstallAddon(addon);
|
|
addon = await AddonManager.getAddonByID(addonId);
|
|
ok(!addon, "add-on should be uninstalled");
|
|
}
|
|
}
|
|
});
|
|
await decorated();
|
|
};
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Test decorator to provide a web extension installed from a URL.
|
|
*/
|
|
this.withInstalledWebExtensionFromURL = function (
|
|
url,
|
|
{ as = "installedWebExtension" } = {}
|
|
) {
|
|
return function wrapper(testFunction) {
|
|
return async function wrappedTestFunction(args) {
|
|
let startupPromise;
|
|
let addonId;
|
|
|
|
const install = await AddonManager.getInstallForURL(url);
|
|
const listener = {
|
|
onInstallStarted(cbInstall) {
|
|
addonId = cbInstall.addon.id;
|
|
startupPromise = AddonTestUtils.promiseWebExtensionStartup(addonId);
|
|
},
|
|
};
|
|
install.addListener(listener);
|
|
|
|
await install.install();
|
|
await startupPromise;
|
|
|
|
try {
|
|
await testFunction({ ...args, [as]: { addonId, url } });
|
|
} finally {
|
|
const addonToUninstall = await AddonManager.getAddonByID(addonId);
|
|
await safeUninstallAddon(addonToUninstall);
|
|
}
|
|
};
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Test decorator that checks that the test cleans up all add-ons installed
|
|
* during the test. Likely needs to be the first decorator used.
|
|
*/
|
|
this.ensureAddonCleanup = function () {
|
|
return function (testFunction) {
|
|
return async function wrappedTestFunction(args) {
|
|
const beforeAddons = new Set(await AddonManager.getAllAddons());
|
|
|
|
try {
|
|
await testFunction(args);
|
|
} finally {
|
|
const afterAddons = new Set(await AddonManager.getAllAddons());
|
|
Assert.deepEqual(
|
|
beforeAddons,
|
|
afterAddons,
|
|
"The add-ons should be same before and after the test"
|
|
);
|
|
}
|
|
};
|
|
};
|
|
};
|
|
|
|
class MockHeartbeat {
|
|
constructor() {
|
|
this.eventEmitter = new MockEventEmitter();
|
|
}
|
|
}
|
|
|
|
class MockEventEmitter {
|
|
constructor() {
|
|
this.once = sinon.stub();
|
|
}
|
|
}
|
|
|
|
function withStubbedHeartbeat() {
|
|
return function (testFunction) {
|
|
return async function wrappedTestFunction(args) {
|
|
const heartbeatInstanceStub = new MockHeartbeat();
|
|
const heartbeatClassStub = sinon.stub();
|
|
heartbeatClassStub.returns(heartbeatInstanceStub);
|
|
ShowHeartbeatAction.overrideHeartbeatForTests(heartbeatClassStub);
|
|
|
|
try {
|
|
await testFunction({
|
|
...args,
|
|
heartbeatClassStub,
|
|
heartbeatInstanceStub,
|
|
});
|
|
} finally {
|
|
ShowHeartbeatAction.overrideHeartbeatForTests();
|
|
}
|
|
};
|
|
};
|
|
}
|
|
|
|
function withClearStorage() {
|
|
return function (testFunction) {
|
|
return async function wrappedTestFunction(args) {
|
|
Storage.clearAllStorage();
|
|
try {
|
|
await testFunction(args);
|
|
} finally {
|
|
Storage.clearAllStorage();
|
|
}
|
|
};
|
|
};
|
|
}
|