зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1359655 - Sync shield-recipe-client from GitHub (commit e37261c) r=Gijs
MozReview-Commit-ID: E0d4mgliHzA --HG-- rename : browser/extensions/shield-recipe-client/test/unit/test_server.sjs => browser/extensions/shield-recipe-client/test/unit/query_server.sjs extra : rebase_source : 6eba573ba0500808cfe4404835ad612957e2f34e
This commit is contained in:
Родитель
9a6937d9cb
Коммит
8b3f66fa72
|
@ -4,81 +4,27 @@
|
|||
"use strict";
|
||||
|
||||
const {utils: Cu} = Components;
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "LogManager",
|
||||
"resource://shield-recipe-client/lib/LogManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "RecipeRunner",
|
||||
"resource://shield-recipe-client/lib/RecipeRunner.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "CleanupManager",
|
||||
"resource://shield-recipe-client/lib/CleanupManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ShieldRecipeClient",
|
||||
"resource://shield-recipe-client/lib/ShieldRecipeClient.jsm");
|
||||
|
||||
const REASONS = {
|
||||
APP_STARTUP: 1, // The application is starting up.
|
||||
APP_SHUTDOWN: 2, // The application is shutting down.
|
||||
ADDON_ENABLE: 3, // The add-on is being enabled.
|
||||
ADDON_DISABLE: 4, // The add-on is being disabled. (Also sent during uninstallation)
|
||||
ADDON_INSTALL: 5, // The add-on is being installed.
|
||||
ADDON_UNINSTALL: 6, // The add-on is being uninstalled.
|
||||
ADDON_UPGRADE: 7, // The add-on is being upgraded.
|
||||
ADDON_DOWNGRADE: 8, // The add-on is being downgraded.
|
||||
};
|
||||
this.install = function() {};
|
||||
|
||||
const PREF_BRANCH = "extensions.shield-recipe-client.";
|
||||
const DEFAULT_PREFS = {
|
||||
api_url: "https://normandy.cdn.mozilla.net/api/v1",
|
||||
dev_mode: false,
|
||||
enabled: true,
|
||||
startup_delay_seconds: 300,
|
||||
"logging.level": Log.Level.Warn,
|
||||
user_id: "",
|
||||
};
|
||||
const PREF_DEV_MODE = "extensions.shield-recipe-client.dev_mode";
|
||||
const PREF_SELF_SUPPORT_ENABLED = "browser.selfsupport.enabled";
|
||||
const PREF_LOGGING_LEVEL = PREF_BRANCH + "logging.level";
|
||||
|
||||
let shouldRun = true;
|
||||
let log = null;
|
||||
|
||||
this.install = function() {
|
||||
// Self Repair only checks its pref on start, so if we disable it, wait until
|
||||
// next startup to run, unless the dev_mode preference is set.
|
||||
if (Preferences.get(PREF_SELF_SUPPORT_ENABLED, true)) {
|
||||
Preferences.set(PREF_SELF_SUPPORT_ENABLED, false);
|
||||
if (!Preferences.get(PREF_DEV_MODE, false)) {
|
||||
shouldRun = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.startup = function() {
|
||||
setDefaultPrefs();
|
||||
|
||||
// Setup logging and listen for changes to logging prefs
|
||||
LogManager.configure(Services.prefs.getIntPref(PREF_LOGGING_LEVEL));
|
||||
log = LogManager.getLogger("bootstrap");
|
||||
Preferences.observe(PREF_LOGGING_LEVEL, LogManager.configure);
|
||||
CleanupManager.addCleanupHandler(
|
||||
() => Preferences.ignore(PREF_LOGGING_LEVEL, LogManager.configure));
|
||||
|
||||
if (!shouldRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
RecipeRunner.init();
|
||||
this.startup = async function() {
|
||||
await ShieldRecipeClient.startup();
|
||||
};
|
||||
|
||||
this.shutdown = function(data, reason) {
|
||||
CleanupManager.cleanup();
|
||||
|
||||
if (reason === REASONS.ADDON_DISABLE || reason === REASONS.ADDON_UNINSTALL) {
|
||||
Services.prefs.setBoolPref(PREF_SELF_SUPPORT_ENABLED, true);
|
||||
}
|
||||
ShieldRecipeClient.shutdown(reason);
|
||||
|
||||
// Unload add-on modules. We don't do this in ShieldRecipeClient so that
|
||||
// modules are not unloaded accidentally during tests.
|
||||
const log = LogManager.getLogger("bootstrap");
|
||||
const modules = [
|
||||
"lib/ActionSandboxManager.jsm",
|
||||
"lib/CleanupManager.jsm",
|
||||
"lib/ClientEnvironment.jsm",
|
||||
"lib/FilterExpressions.jsm",
|
||||
|
@ -87,29 +33,18 @@ this.shutdown = function(data, reason) {
|
|||
"lib/LogManager.jsm",
|
||||
"lib/NormandyApi.jsm",
|
||||
"lib/NormandyDriver.jsm",
|
||||
"lib/PreferenceExperiments.jsm",
|
||||
"lib/RecipeRunner.jsm",
|
||||
"lib/Sampling.jsm",
|
||||
"lib/SandboxManager.jsm",
|
||||
"lib/ShieldRecipeClient.jsm",
|
||||
"lib/Storage.jsm",
|
||||
"lib/Utils.jsm",
|
||||
];
|
||||
for (const module of modules) {
|
||||
log.debug(`Unloading ${module}`);
|
||||
Cu.unload(`resource://shield-recipe-client/${module}`);
|
||||
}
|
||||
|
||||
// Don't forget the logger!
|
||||
log = null;
|
||||
};
|
||||
|
||||
this.uninstall = function() {
|
||||
};
|
||||
|
||||
function setDefaultPrefs() {
|
||||
for (const [key, val] of Object.entries(DEFAULT_PREFS)) {
|
||||
const fullKey = PREF_BRANCH + key;
|
||||
// If someone beat us to setting a default, don't overwrite it.
|
||||
if (!Preferences.isSet(fullKey)) {
|
||||
Preferences.set(fullKey, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.uninstall = function() {};
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<em:type>2</em:type>
|
||||
<em:bootstrap>true</em:bootstrap>
|
||||
<em:unpack>false</em:unpack>
|
||||
<em:version>1.0.0</em:version>
|
||||
<em:version>51</em:version>
|
||||
<em:name>Shield Recipe Client</em:name>
|
||||
<em:description>Client to download and run recipes for SHIELD, Heartbeat, etc.</em:description>
|
||||
<em:multiprocessCompatible>true</em:multiprocessCompatible>
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use strict";
|
||||
|
||||
const {utils: Cu} = Components;
|
||||
Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["ActionSandboxManager"];
|
||||
|
||||
const log = LogManager.getLogger("recipe-sandbox-manager");
|
||||
|
||||
/**
|
||||
* An extension to SandboxManager that prepares a sandbox for executing
|
||||
* Normandy actions.
|
||||
*
|
||||
* Actions register a set of named callbacks, which this class makes available
|
||||
* for execution. This allows a single action script to define multiple,
|
||||
* independent steps that execute in isolated sandboxes.
|
||||
*
|
||||
* Callbacks are assumed to be async and must return Promises.
|
||||
*/
|
||||
this.ActionSandboxManager = class extends SandboxManager {
|
||||
constructor(actionScript) {
|
||||
super();
|
||||
|
||||
// Prepare the sandbox environment
|
||||
const driver = new NormandyDriver(this);
|
||||
this.cloneIntoGlobal("sandboxedDriver", driver, {cloneFunctions: true});
|
||||
this.evalInSandbox(`
|
||||
// Shim old API for registering actions
|
||||
function registerAction(name, Action) {
|
||||
registerAsyncCallback("action", (driver, recipe) => {
|
||||
return new Action(driver, recipe).execute();
|
||||
});
|
||||
};
|
||||
|
||||
this.asyncCallbacks = new Map();
|
||||
function registerAsyncCallback(name, callback) {
|
||||
asyncCallbacks.set(name, callback);
|
||||
}
|
||||
|
||||
this.window = this;
|
||||
this.setTimeout = sandboxedDriver.setTimeout;
|
||||
this.clearTimeout = sandboxedDriver.clearTimeout;
|
||||
`);
|
||||
this.evalInSandbox(actionScript);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a callback in the sandbox with the given name. If the script does
|
||||
* not register a callback with the given name, we log a message and return.
|
||||
* @param {String} callbackName
|
||||
* @param {...*} [args]
|
||||
* Remaining arguments are cloned into the sandbox and passed as arguments
|
||||
* to the callback.
|
||||
* @resolves
|
||||
* The return value of the callback, cloned into the current compartment, or
|
||||
* undefined if a matching callback was not found.
|
||||
* @rejects
|
||||
* If the sandbox rejects, an error object with the message from the sandbox
|
||||
* error. Due to sandbox limitations, the stack trace is not preserved.
|
||||
*/
|
||||
async runAsyncCallback(callbackName, ...args) {
|
||||
const callbackWasRegistered = this.evalInSandbox(`asyncCallbacks.has("${callbackName}")`);
|
||||
if (!callbackWasRegistered) {
|
||||
log.debug(`Script did not register a callback with the name "${callbackName}"`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.cloneIntoGlobal("callbackArgs", args);
|
||||
try {
|
||||
const result = await this.evalInSandbox(`
|
||||
asyncCallbacks.get("${callbackName}")(sandboxedDriver, ...callbackArgs);
|
||||
`);
|
||||
return Cu.cloneInto(result, {});
|
||||
} catch (err) {
|
||||
throw new Error(Cu.cloneInto(err.message, {}));
|
||||
}
|
||||
}
|
||||
};
|
|
@ -7,13 +7,18 @@
|
|||
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ShellService", "resource:///modules/ShellService.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "TelemetryArchive", "resource://gre/modules/TelemetryArchive.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "NormandyApi", "resource://shield-recipe-client/lib/NormandyApi.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(
|
||||
this,
|
||||
"PreferenceExperiments",
|
||||
"resource://shield-recipe-client/lib/PreferenceExperiments.jsm",
|
||||
);
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Utils", "resource://shield-recipe-client/lib/Utils.jsm");
|
||||
|
||||
const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
|
||||
|
||||
|
@ -31,12 +36,12 @@ this.ClientEnvironment = {
|
|||
* The server request is made lazily and is cached for the entire browser
|
||||
* session.
|
||||
*/
|
||||
getClientClassification: Task.async(function *() {
|
||||
async getClientClassification() {
|
||||
if (!_classifyRequest) {
|
||||
_classifyRequest = NormandyApi.classifyClient();
|
||||
}
|
||||
return yield _classifyRequest;
|
||||
}),
|
||||
return await _classifyRequest;
|
||||
},
|
||||
|
||||
clearClassifyCache() {
|
||||
_classifyRequest = null;
|
||||
|
@ -45,13 +50,13 @@ this.ClientEnvironment = {
|
|||
/**
|
||||
* Test wrapper that mocks the server request for classifying the client.
|
||||
* @param {Object} data Fake server data to use
|
||||
* @param {Function} testGenerator Test generator to execute while mock data is in effect.
|
||||
* @param {Function} testFunction Test function to execute while mock data is in effect.
|
||||
*/
|
||||
withMockClassify(data, testGenerator) {
|
||||
return function* inner() {
|
||||
withMockClassify(data, testFunction) {
|
||||
return async function inner() {
|
||||
const oldRequest = _classifyRequest;
|
||||
_classifyRequest = Promise.resolve(data);
|
||||
yield testGenerator();
|
||||
await testFunction();
|
||||
_classifyRequest = oldRequest;
|
||||
};
|
||||
},
|
||||
|
@ -94,8 +99,8 @@ this.ClientEnvironment = {
|
|||
return Preferences.get("distribution.id", "default");
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(environment, "telemetry", Task.async(function *() {
|
||||
const pings = yield TelemetryArchive.promiseArchivedPingList();
|
||||
XPCOMUtils.defineLazyGetter(environment, "telemetry", async function() {
|
||||
const pings = await TelemetryArchive.promiseArchivedPingList();
|
||||
|
||||
// get most recent ping per type
|
||||
const mostRecentPings = {};
|
||||
|
@ -112,10 +117,10 @@ this.ClientEnvironment = {
|
|||
const telemetry = {};
|
||||
for (const key in mostRecentPings) {
|
||||
const ping = mostRecentPings[key];
|
||||
telemetry[ping.type] = yield TelemetryArchive.promiseArchivedPingById(ping.id);
|
||||
telemetry[ping.type] = await TelemetryArchive.promiseArchivedPingById(ping.id);
|
||||
}
|
||||
return telemetry;
|
||||
}));
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(environment, "version", () => {
|
||||
return Services.appinfo.version;
|
||||
|
@ -129,13 +134,13 @@ this.ClientEnvironment = {
|
|||
return ShellService.isDefaultBrowser();
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(environment, "searchEngine", Task.async(function* () {
|
||||
const searchInitialized = yield new Promise(resolve => Services.search.init(resolve));
|
||||
XPCOMUtils.defineLazyGetter(environment, "searchEngine", async function() {
|
||||
const searchInitialized = await new Promise(resolve => Services.search.init(resolve));
|
||||
if (Components.isSuccessCode(searchInitialized)) {
|
||||
return Services.search.defaultEngine.identifier;
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(environment, "syncSetup", () => {
|
||||
return Preferences.isSet("services.sync.username");
|
||||
|
@ -150,31 +155,48 @@ this.ClientEnvironment = {
|
|||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(environment, "syncTotalDevices", () => {
|
||||
return Preferences.get("services.sync.numClients", 0);
|
||||
return environment.syncDesktopDevices + environment.syncMobileDevices;
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(environment, "plugins", Task.async(function* () {
|
||||
const plugins = yield AddonManager.getAddonsByTypes(["plugin"]);
|
||||
return plugins.reduce((pluginMap, plugin) => {
|
||||
pluginMap[plugin.name] = {
|
||||
XPCOMUtils.defineLazyGetter(environment, "plugins", async function() {
|
||||
let plugins = await AddonManager.getAddonsByTypes(["plugin"]);
|
||||
plugins = plugins.map(plugin => ({
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
version: plugin.version,
|
||||
};
|
||||
return pluginMap;
|
||||
}, {});
|
||||
}));
|
||||
return Utils.keyBy(plugins, "name");
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(environment, "locale", () => {
|
||||
if (Services.locale.getAppLocaleAsLangTag) {
|
||||
return Services.locale.getAppLocaleAsLangTag();
|
||||
}
|
||||
|
||||
return Cc["@mozilla.org/chrome/chrome-registry;1"]
|
||||
.getService(Ci.nsIXULChromeRegistry)
|
||||
.getSelectedLocale("browser");
|
||||
.getSelectedLocale("global");
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(environment, "doNotTrack", () => {
|
||||
return Preferences.get("privacy.donottrackheader.enabled", false);
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(environment, "experiments", async () => {
|
||||
const names = {all: [], active: [], expired: []};
|
||||
|
||||
for (const experiment of await PreferenceExperiments.getAll()) {
|
||||
names.all.push(experiment.name);
|
||||
if (experiment.expired) {
|
||||
names.expired.push(experiment.name);
|
||||
} else {
|
||||
names.active.push(experiment.name);
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
});
|
||||
|
||||
return environment;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
const {utils: Cu} = Components;
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/Sampling.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/PreferenceFilters.jsm");
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["FilterExpressions"];
|
||||
|
||||
|
@ -27,6 +28,9 @@ XPCOMUtils.defineLazyGetter(this, "jexl", () => {
|
|||
date: dateString => new Date(dateString),
|
||||
stableSample: Sampling.stableSample,
|
||||
bucketSample: Sampling.bucketSample,
|
||||
preferenceValue: PreferenceFilters.preferenceValue,
|
||||
preferenceIsUserSet: PreferenceFilters.preferenceIsUserSet,
|
||||
preferenceExists: PreferenceFilters.preferenceExists,
|
||||
});
|
||||
return jexl;
|
||||
});
|
||||
|
|
|
@ -6,9 +6,9 @@
|
|||
|
||||
const {utils: Cu, classes: Cc, interfaces: Ci} = Components;
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
Cu.import("resource://gre/modules/CanonicalJSON.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/Utils.jsm");
|
||||
Cu.importGlobalProperties(["fetch", "URL"]); /* globals fetch, URL */
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["NormandyApi"];
|
||||
|
@ -61,23 +61,23 @@ this.NormandyApi = {
|
|||
throw new Error("Can't use relative urls");
|
||||
},
|
||||
|
||||
getApiUrl: Task.async(function * (name) {
|
||||
async getApiUrl(name) {
|
||||
const apiBase = prefs.getCharPref("api_url");
|
||||
if (!indexPromise) {
|
||||
indexPromise = this.get(apiBase).then(res => res.json());
|
||||
}
|
||||
const index = yield indexPromise;
|
||||
const index = await indexPromise;
|
||||
if (!(name in index)) {
|
||||
throw new Error(`API endpoint with name "${name}" not found.`);
|
||||
}
|
||||
const url = index[name];
|
||||
return this.absolutify(url);
|
||||
}),
|
||||
},
|
||||
|
||||
fetchRecipes: Task.async(function* (filters = {enabled: true}) {
|
||||
const signedRecipesUrl = yield this.getApiUrl("recipe-signed");
|
||||
const recipesResponse = yield this.get(signedRecipesUrl, filters);
|
||||
const rawText = yield recipesResponse.text();
|
||||
async fetchRecipes(filters = {enabled: true}) {
|
||||
const signedRecipesUrl = await this.getApiUrl("recipe-signed");
|
||||
const recipesResponse = await this.get(signedRecipesUrl, filters);
|
||||
const rawText = await recipesResponse.text();
|
||||
const recipesWithSigs = JSON.parse(rawText);
|
||||
|
||||
const verifiedRecipes = [];
|
||||
|
@ -89,8 +89,8 @@ this.NormandyApi = {
|
|||
throw new Error("Canonical recipe serialization does not match!");
|
||||
}
|
||||
|
||||
const certChainResponse = yield fetch(this.absolutify(x5u));
|
||||
const certChain = yield certChainResponse.text();
|
||||
const certChainResponse = await fetch(this.absolutify(x5u));
|
||||
const certChain = await certChainResponse.text();
|
||||
const builtSignature = `p384ecdsa=${signature}`;
|
||||
|
||||
const verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
|
||||
|
@ -114,26 +114,36 @@ this.NormandyApi = {
|
|||
);
|
||||
|
||||
return verifiedRecipes;
|
||||
}),
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch metadata about this client determined by the server.
|
||||
* @return {object} Metadata specified by the server
|
||||
*/
|
||||
classifyClient: Task.async(function* () {
|
||||
const classifyClientUrl = yield this.getApiUrl("classify-client");
|
||||
const response = yield this.get(classifyClientUrl);
|
||||
const clientData = yield response.json();
|
||||
async classifyClient() {
|
||||
const classifyClientUrl = await this.getApiUrl("classify-client");
|
||||
const response = await this.get(classifyClientUrl);
|
||||
const clientData = await response.json();
|
||||
clientData.request_time = new Date(clientData.request_time);
|
||||
return clientData;
|
||||
}),
|
||||
},
|
||||
|
||||
fetchAction: Task.async(function* (name) {
|
||||
let actionApiUrl = yield this.getApiUrl("action-list");
|
||||
if (!actionApiUrl.endsWith("/")) {
|
||||
actionApiUrl += "/";
|
||||
/**
|
||||
* Fetch an array of available actions from the server.
|
||||
* @resolves {Array}
|
||||
*/
|
||||
async fetchActions() {
|
||||
const actionApiUrl = await this.getApiUrl("action-list");
|
||||
const res = await this.get(actionApiUrl);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
async fetchImplementation(action) {
|
||||
const response = await fetch(action.implementation_url);
|
||||
if (response.ok) {
|
||||
return await response.text();
|
||||
}
|
||||
const res = yield this.get(actionApiUrl + name);
|
||||
return yield res.json();
|
||||
}),
|
||||
|
||||
throw new Error(`Failed to fetch action implementation for ${action.name}: ${response.status}`);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use strict";
|
||||
/* globals Components */
|
||||
|
||||
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
||||
|
||||
|
@ -17,6 +16,8 @@ Cu.import("resource://shield-recipe-client/lib/Storage.jsm");
|
|||
Cu.import("resource://shield-recipe-client/lib/Heartbeat.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/FilterExpressions.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/ClientEnvironment.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/PreferenceExperiments.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/Sampling.jsm");
|
||||
|
||||
const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
|
||||
|
||||
|
@ -35,9 +36,13 @@ this.NormandyDriver = function(sandboxManager) {
|
|||
testing: false,
|
||||
|
||||
get locale() {
|
||||
if (Services.locale.getAppLocaleAsLangTag) {
|
||||
return Services.locale.getAppLocaleAsLangTag();
|
||||
}
|
||||
|
||||
return Cc["@mozilla.org/chrome/chrome-registry;1"]
|
||||
.getService(Ci.nsIXULChromeRegistry)
|
||||
.getSelectedLocale("browser");
|
||||
.getSelectedLocale("global");
|
||||
},
|
||||
|
||||
get userId() {
|
||||
|
@ -78,11 +83,12 @@ this.NormandyDriver = function(sandboxManager) {
|
|||
syncSetup: Preferences.isSet("services.sync.username"),
|
||||
syncDesktopDevices: Preferences.get("services.sync.clients.devices.desktop", 0),
|
||||
syncMobileDevices: Preferences.get("services.sync.clients.devices.mobile", 0),
|
||||
syncTotalDevices: Preferences.get("services.sync.numClients", 0),
|
||||
syncTotalDevices: null,
|
||||
plugins: {},
|
||||
doNotTrack: Preferences.get("privacy.donottrackheader.enabled", false),
|
||||
distribution: Preferences.get("distribution.id", "default"),
|
||||
};
|
||||
appinfo.syncTotalDevices = appinfo.syncDesktopDevices + appinfo.syncMobileDevices;
|
||||
|
||||
const searchEnginePromise = new Promise(resolve => {
|
||||
Services.search.init(rv => {
|
||||
|
@ -144,5 +150,18 @@ this.NormandyDriver = function(sandboxManager) {
|
|||
clearTimeout(token);
|
||||
sandboxManager.removeHold(`setTimeout-${token}`);
|
||||
},
|
||||
|
||||
// Sampling
|
||||
ratioSample: sandboxManager.wrapAsync(Sampling.ratioSample),
|
||||
|
||||
// Preference Experiment API
|
||||
preferenceExperiments: {
|
||||
start: sandboxManager.wrapAsync(PreferenceExperiments.start, {cloneArguments: true}),
|
||||
markLastSeen: sandboxManager.wrapAsync(PreferenceExperiments.markLastSeen),
|
||||
stop: sandboxManager.wrapAsync(PreferenceExperiments.stop),
|
||||
get: sandboxManager.wrapAsync(PreferenceExperiments.get, {cloneInto: true}),
|
||||
getAllActive: sandboxManager.wrapAsync(PreferenceExperiments.getAllActive, {cloneInto: true}),
|
||||
has: sandboxManager.wrapAsync(PreferenceExperiments.has),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -0,0 +1,424 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
/**
|
||||
* Preference Experiments temporarily change a preference to one of several test
|
||||
* values for the duration of the experiment. Telemetry packets are annotated to
|
||||
* show what experiments are active, and we use this data to measure the
|
||||
* effectiveness of the preference change.
|
||||
*
|
||||
* Info on active and past experiments is stored in a JSON file in the profile
|
||||
* folder.
|
||||
*
|
||||
* Active preference experiments are stopped if they aren't active on the recipe
|
||||
* server. They also expire if Firefox isn't able to contact the recipe server
|
||||
* after a period of time, as well as if the user modifies the preference during
|
||||
* an active experiment.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Experiments store info about an active or expired preference experiment.
|
||||
* They are single-depth objects to simplify cloning.
|
||||
* @typedef {Object} Experiment
|
||||
* @property {string} name
|
||||
* Unique name of the experiment
|
||||
* @property {string} branch
|
||||
* Experiment branch that the user was matched to
|
||||
* @property {boolean} expired
|
||||
* If false, the experiment is active.
|
||||
* @property {string} lastSeen
|
||||
* ISO-formatted date string of when the experiment was last seen from the
|
||||
* recipe server.
|
||||
* @property {string} preferenceName
|
||||
* Name of the preference affected by this experiment.
|
||||
* @property {string|integer|boolean} preferenceValue
|
||||
* Value to change the preference to during the experiment.
|
||||
* @property {string} preferenceType
|
||||
* Type of the preference value being set.
|
||||
* @property {string|integer|boolean|undefined} previousPreferenceValue
|
||||
* Value of the preference prior to the experiment, or undefined if it was
|
||||
* unset.
|
||||
* @property {PreferenceBranchType} preferenceBranchType
|
||||
* Controls how we modify the preference to affect the client.
|
||||
* @rejects {Error}
|
||||
* If the given preferenceType does not match the existing stored preference.
|
||||
*
|
||||
* If "default", when the experiment is active, the default value for the
|
||||
* preference is modified on startup of the add-on. If "user", the user value
|
||||
* for the preference is modified when the experiment starts, and is reset to
|
||||
* its original value when the experiment ends.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const {utils: Cu} = Components;
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Services", "resource://gre/modules/Services.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "CleanupManager", "resource://shield-recipe-client/lib/CleanupManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "JSONFile", "resource://gre/modules/JSONFile.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "LogManager", "resource://shield-recipe-client/lib/LogManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Preferences", "resource://gre/modules/Preferences.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "TelemetryEnvironment", "resource://gre/modules/TelemetryEnvironment.jsm");
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["PreferenceExperiments"];
|
||||
|
||||
const EXPERIMENT_FILE = "shield-preference-experiments.json";
|
||||
|
||||
const PREFERENCE_TYPE_MAP = {
|
||||
boolean: Services.prefs.PREF_BOOL,
|
||||
string: Services.prefs.PREF_STRING,
|
||||
integer: Services.prefs.PREF_INT,
|
||||
};
|
||||
|
||||
const DefaultPreferences = new Preferences({defaultBranch: true});
|
||||
|
||||
/**
|
||||
* Enum storing Preference modules for each type of preference branch.
|
||||
* @enum {Object}
|
||||
*/
|
||||
const PreferenceBranchType = {
|
||||
user: Preferences,
|
||||
default: DefaultPreferences,
|
||||
};
|
||||
|
||||
/**
|
||||
* Asynchronously load the JSON file that stores experiment status in the profile.
|
||||
*/
|
||||
let storePromise;
|
||||
function ensureStorage() {
|
||||
if (storePromise === undefined) {
|
||||
const path = OS.Path.join(OS.Constants.Path.profileDir, EXPERIMENT_FILE);
|
||||
const storage = new JSONFile({path});
|
||||
storePromise = storage.load().then(() => storage);
|
||||
}
|
||||
return storePromise;
|
||||
}
|
||||
|
||||
const log = LogManager.getLogger("preference-experiments");
|
||||
|
||||
// List of active preference observers. Cleaned up on shutdown.
|
||||
let experimentObservers = new Map();
|
||||
CleanupManager.addCleanupHandler(() => PreferenceExperiments.stopAllObservers());
|
||||
|
||||
this.PreferenceExperiments = {
|
||||
/**
|
||||
* Set the default preference value for active experiments that use the
|
||||
* default preference branch.
|
||||
*/
|
||||
async init() {
|
||||
for (const experiment of await this.getAllActive()) {
|
||||
// Set experiment default preferences, since they don't persist between restarts
|
||||
if (experiment.preferenceBranchType === "default") {
|
||||
DefaultPreferences.set(experiment.preferenceName, experiment.preferenceValue);
|
||||
}
|
||||
|
||||
// Check that the current value of the preference is still what we set it to
|
||||
if (Preferences.get(experiment.preferenceName, undefined) !== experiment.preferenceValue) {
|
||||
// if not, stop the experiment, and skip the remaining steps
|
||||
log.info(`Stopping experiment "${experiment.name}" because its value changed`);
|
||||
await this.stop(experiment.name, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Notify Telemetry of experiments we're running, since they don't persist between restarts
|
||||
TelemetryEnvironment.setExperimentActive(experiment.name, experiment.branch);
|
||||
|
||||
// Watch for changes to the experiment's preference
|
||||
this.startObserver(experiment.name, experiment.preferenceName, experiment.preferenceValue);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Test wrapper that temporarily replaces the stored experiment data with fake
|
||||
* data for testing.
|
||||
*/
|
||||
withMockExperiments(testFunction) {
|
||||
return async function inner(...args) {
|
||||
const oldPromise = storePromise;
|
||||
const mockExperiments = {};
|
||||
storePromise = Promise.resolve({
|
||||
data: mockExperiments,
|
||||
saveSoon() { },
|
||||
});
|
||||
const oldObservers = experimentObservers;
|
||||
experimentObservers = new Map();
|
||||
try {
|
||||
await testFunction(...args, mockExperiments);
|
||||
} finally {
|
||||
storePromise = oldPromise;
|
||||
PreferenceExperiments.stopAllObservers();
|
||||
experimentObservers = oldObservers;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all stored data about active and past experiments.
|
||||
*/
|
||||
async clearAllExperimentStorage() {
|
||||
const store = await ensureStorage();
|
||||
store.data = {};
|
||||
store.saveSoon();
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a new preference experiment.
|
||||
* @param {Object} experiment
|
||||
* @param {string} experiment.name
|
||||
* @param {string} experiment.branch
|
||||
* @param {string} experiment.preferenceName
|
||||
* @param {string|integer|boolean} experiment.preferenceValue
|
||||
* @param {PreferenceBranchType} experiment.preferenceBranchType
|
||||
* @rejects {Error}
|
||||
* If an experiment with the given name already exists, or if an experiment
|
||||
* for the given preference is active.
|
||||
*/
|
||||
async start({name, branch, preferenceName, preferenceValue, preferenceBranchType, preferenceType}) {
|
||||
log.debug(`PreferenceExperiments.start(${name}, ${branch})`);
|
||||
|
||||
const store = await ensureStorage();
|
||||
if (name in store.data) {
|
||||
throw new Error(`A preference experiment named "${name}" already exists.`);
|
||||
}
|
||||
|
||||
const activeExperiments = Object.values(store.data).filter(e => !e.expired);
|
||||
const hasConflictingExperiment = activeExperiments.some(
|
||||
e => e.preferenceName === preferenceName
|
||||
);
|
||||
if (hasConflictingExperiment) {
|
||||
throw new Error(
|
||||
`Another preference experiment for the pref "${preferenceName}" is currently active.`
|
||||
);
|
||||
}
|
||||
|
||||
const preferences = PreferenceBranchType[preferenceBranchType];
|
||||
if (!preferences) {
|
||||
throw new Error(`Invalid value for preferenceBranchType: ${preferenceBranchType}`);
|
||||
}
|
||||
|
||||
/** @type {Experiment} */
|
||||
const experiment = {
|
||||
name,
|
||||
branch,
|
||||
expired: false,
|
||||
lastSeen: new Date().toJSON(),
|
||||
preferenceName,
|
||||
preferenceValue,
|
||||
preferenceType,
|
||||
previousPreferenceValue: preferences.get(preferenceName, undefined),
|
||||
preferenceBranchType,
|
||||
};
|
||||
|
||||
const prevPrefType = Services.prefs.getPrefType(preferenceName);
|
||||
const givenPrefType = PREFERENCE_TYPE_MAP[preferenceType];
|
||||
|
||||
if (!preferenceType || !givenPrefType) {
|
||||
throw new Error(`Invalid preferenceType provided (given "${preferenceType}")`);
|
||||
}
|
||||
|
||||
if (prevPrefType !== Services.prefs.PREF_INVALID && prevPrefType !== givenPrefType) {
|
||||
throw new Error(
|
||||
`Previous preference value is of type "${prevPrefType}", but was given "${givenPrefType}" (${preferenceType})`
|
||||
);
|
||||
}
|
||||
|
||||
preferences.set(preferenceName, preferenceValue);
|
||||
PreferenceExperiments.startObserver(name, preferenceName, preferenceValue);
|
||||
store.data[name] = experiment;
|
||||
store.saveSoon();
|
||||
|
||||
TelemetryEnvironment.setExperimentActive(name, branch);
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a preference observer that stops an experiment when the user
|
||||
* modifies the preference.
|
||||
* @param {string} experimentName
|
||||
* @param {string} preferenceName
|
||||
* @param {string|integer|boolean} preferenceValue
|
||||
* @throws {Error}
|
||||
* If an observer for the named experiment is already active.
|
||||
*/
|
||||
startObserver(experimentName, preferenceName, preferenceValue) {
|
||||
log.debug(`PreferenceExperiments.startObserver(${experimentName})`);
|
||||
|
||||
if (experimentObservers.has(experimentName)) {
|
||||
throw new Error(
|
||||
`An observer for the preference experiment ${experimentName} is already active.`
|
||||
);
|
||||
}
|
||||
|
||||
const observerInfo = {
|
||||
preferenceName,
|
||||
observer(newValue) {
|
||||
if (newValue !== preferenceValue) {
|
||||
PreferenceExperiments.stop(experimentName, false);
|
||||
}
|
||||
},
|
||||
};
|
||||
experimentObservers.set(experimentName, observerInfo);
|
||||
Preferences.observe(preferenceName, observerInfo.observer);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a preference observer is active for an experiment.
|
||||
* @param {string} experimentName
|
||||
* @return {Boolean}
|
||||
*/
|
||||
hasObserver(experimentName) {
|
||||
log.debug(`PreferenceExperiments.hasObserver(${experimentName})`);
|
||||
return experimentObservers.has(experimentName);
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable a preference observer for the named experiment.
|
||||
* @param {string} experimentName
|
||||
* @throws {Error}
|
||||
* If there is no active observer for the named experiment.
|
||||
*/
|
||||
stopObserver(experimentName) {
|
||||
log.debug(`PreferenceExperiments.stopObserver(${experimentName})`);
|
||||
|
||||
if (!experimentObservers.has(experimentName)) {
|
||||
throw new Error(`No observer for the preference experiment ${experimentName} found.`);
|
||||
}
|
||||
|
||||
const {preferenceName, observer} = experimentObservers.get(experimentName);
|
||||
Preferences.ignore(preferenceName, observer);
|
||||
experimentObservers.delete(experimentName);
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable all currently-active preference observers for experiments.
|
||||
*/
|
||||
stopAllObservers() {
|
||||
log.debug("PreferenceExperiments.stopAllObservers()");
|
||||
for (const {preferenceName, observer} of experimentObservers.values()) {
|
||||
Preferences.ignore(preferenceName, observer);
|
||||
}
|
||||
experimentObservers.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the timestamp storing when Normandy last sent a recipe for the named
|
||||
* experiment.
|
||||
* @param {string} experimentName
|
||||
* @rejects {Error}
|
||||
* If there is no stored experiment with the given name.
|
||||
*/
|
||||
async markLastSeen(experimentName) {
|
||||
log.debug(`PreferenceExperiments.markLastSeen(${experimentName})`);
|
||||
|
||||
const store = await ensureStorage();
|
||||
if (!(experimentName in store.data)) {
|
||||
throw new Error(`Could not find a preference experiment named "${experimentName}"`);
|
||||
}
|
||||
|
||||
store.data[experimentName].lastSeen = new Date().toJSON();
|
||||
store.saveSoon();
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop an active experiment, deactivate preference watchers, and optionally
|
||||
* reset the associated preference to its previous value.
|
||||
* @param {string} experimentName
|
||||
* @param {boolean} [resetValue=true]
|
||||
* If true, reset the preference to its original value.
|
||||
* @rejects {Error}
|
||||
* If there is no stored experiment with the given name, or if the
|
||||
* experiment has already expired.
|
||||
*/
|
||||
async stop(experimentName, resetValue = true) {
|
||||
log.debug(`PreferenceExperiments.stop(${experimentName})`);
|
||||
|
||||
const store = await ensureStorage();
|
||||
if (!(experimentName in store.data)) {
|
||||
throw new Error(`Could not find a preference experiment named "${experimentName}"`);
|
||||
}
|
||||
|
||||
const experiment = store.data[experimentName];
|
||||
if (experiment.expired) {
|
||||
throw new Error(
|
||||
`Cannot stop preference experiment "${experimentName}" because it is already expired`
|
||||
);
|
||||
}
|
||||
|
||||
if (PreferenceExperiments.hasObserver(experimentName)) {
|
||||
PreferenceExperiments.stopObserver(experimentName);
|
||||
}
|
||||
|
||||
if (resetValue) {
|
||||
const {preferenceName, previousPreferenceValue, preferenceBranchType} = experiment;
|
||||
const preferences = PreferenceBranchType[preferenceBranchType];
|
||||
if (previousPreferenceValue !== undefined) {
|
||||
preferences.set(preferenceName, previousPreferenceValue);
|
||||
} else {
|
||||
// This does nothing if we're on the default branch, which is fine. The
|
||||
// preference will be reset on next restart, and most preferences should
|
||||
// have had a default value set before the experiment anyway.
|
||||
preferences.reset(preferenceName);
|
||||
}
|
||||
}
|
||||
|
||||
experiment.expired = true;
|
||||
store.saveSoon();
|
||||
|
||||
TelemetryEnvironment.setExperimentInactive(experimentName, experiment.branch);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the experiment object for the named experiment.
|
||||
* @param {string} experimentName
|
||||
* @resolves {Experiment}
|
||||
* @rejects {Error}
|
||||
* If no preference experiment exists with the given name.
|
||||
*/
|
||||
async get(experimentName) {
|
||||
log.debug(`PreferenceExperiments.get(${experimentName})`);
|
||||
const store = await ensureStorage();
|
||||
if (!(experimentName in store.data)) {
|
||||
throw new Error(`Could not find a preference experiment named "${experimentName}"`);
|
||||
}
|
||||
|
||||
// Return a copy so mutating it doesn't affect the storage.
|
||||
return Object.assign({}, store.data[experimentName]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a list of all stored experiment objects.
|
||||
* @resolves {Experiment[]}
|
||||
*/
|
||||
async getAll() {
|
||||
const store = await ensureStorage();
|
||||
|
||||
// Return copies so that mutating returned experiments doesn't affect the
|
||||
// stored values.
|
||||
return Object.values(store.data).map(experiment => Object.assign({}, experiment));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a list of experiment objects for all active experiments.
|
||||
* @resolves {Experiment[]}
|
||||
*/
|
||||
async getAllActive() {
|
||||
log.debug("PreferenceExperiments.getAllActive()");
|
||||
const store = await ensureStorage();
|
||||
|
||||
// Return copies so mutating them doesn't affect the storage.
|
||||
return Object.values(store.data).filter(e => !e.expired).map(e => Object.assign({}, e));
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an experiment exists with the given name.
|
||||
* @param {string} experimentName
|
||||
* @resolves {boolean} True if the experiment exists, false if it doesn't.
|
||||
*/
|
||||
async has(experimentName) {
|
||||
log.debug(`PreferenceExperiments.has(${experimentName})`);
|
||||
const store = await ensureStorage();
|
||||
return experimentName in store.data;
|
||||
},
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use strict";
|
||||
|
||||
const {utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["PreferenceFilters"];
|
||||
|
||||
this.PreferenceFilters = {
|
||||
// Compare the value of a given preference. Takes a `default` value as an
|
||||
// optional argument to pass into `Preferences.get`.
|
||||
preferenceValue(prefKey, defaultValue) {
|
||||
return Preferences.get(prefKey, defaultValue);
|
||||
},
|
||||
|
||||
// Compare if the preference is user set.
|
||||
preferenceIsUserSet(prefKey) {
|
||||
return Preferences.isSet(prefKey);
|
||||
},
|
||||
|
||||
// Compare if the preference has _any_ value, whether it's user-set or default.
|
||||
preferenceExists(prefKey) {
|
||||
return Preferences.has(prefKey);
|
||||
},
|
||||
};
|
|
@ -6,24 +6,38 @@
|
|||
|
||||
const {utils: Cu} = Components;
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/Timer.jsm"); /* globals setTimeout */
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/FilterExpressions.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/ClientEnvironment.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.importGlobalProperties(["fetch"]); /* globals fetch */
|
||||
Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyServiceGetter(this, "timerManager",
|
||||
"@mozilla.org/updates/timer-manager;1",
|
||||
"nsIUpdateTimerManager");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Preferences", "resource://gre/modules/Preferences.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Storage", "resource://shield-recipe-client/lib/Storage.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Storage",
|
||||
"resource://shield-recipe-client/lib/Storage.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "NormandyDriver",
|
||||
"resource://shield-recipe-client/lib/NormandyDriver.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "FilterExpressions",
|
||||
"resource://shield-recipe-client/lib/FilterExpressions.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "NormandyApi",
|
||||
"resource://shield-recipe-client/lib/NormandyApi.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "SandboxManager",
|
||||
"resource://shield-recipe-client/lib/SandboxManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ClientEnvironment",
|
||||
"resource://shield-recipe-client/lib/ClientEnvironment.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "CleanupManager",
|
||||
"resource://shield-recipe-client/lib/CleanupManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ActionSandboxManager",
|
||||
"resource://shield-recipe-client/lib/ActionSandboxManager.jsm");
|
||||
|
||||
Cu.importGlobalProperties(["fetch"]); /* globals fetch */
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["RecipeRunner"];
|
||||
|
||||
const log = LogManager.getLogger("recipe-runner");
|
||||
const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
|
||||
const TIMER_NAME = "recipe-client-addon-run";
|
||||
const RUN_INTERVAL_PREF = "run_interval_seconds";
|
||||
|
||||
this.RecipeRunner = {
|
||||
init() {
|
||||
|
@ -31,15 +45,17 @@ this.RecipeRunner = {
|
|||
return;
|
||||
}
|
||||
|
||||
let delay;
|
||||
if (prefs.getBoolPref("dev_mode")) {
|
||||
delay = 0;
|
||||
} else {
|
||||
// startup delay is in seconds
|
||||
delay = prefs.getIntPref("startup_delay_seconds") * 1000;
|
||||
// Run right now in dev mode
|
||||
this.run();
|
||||
}
|
||||
|
||||
setTimeout(this.start.bind(this), delay);
|
||||
this.updateRunInterval();
|
||||
CleanupManager.addCleanupHandler(() => timerManager.unregisterTimer(TIMER_NAME));
|
||||
|
||||
// Watch for the run interval to change, and re-register the timer with the new value
|
||||
prefs.addObserver(RUN_INTERVAL_PREF, this);
|
||||
CleanupManager.addCleanupHandler(() => prefs.removeObserver(RUN_INTERVAL_PREF, this));
|
||||
},
|
||||
|
||||
checkPrefs() {
|
||||
|
@ -63,46 +79,132 @@ this.RecipeRunner = {
|
|||
return true;
|
||||
},
|
||||
|
||||
start: Task.async(function* () {
|
||||
/**
|
||||
* Watch for preference changes from Services.pref.addObserver.
|
||||
*/
|
||||
observe(changedPrefBranch, action, changedPref) {
|
||||
if (action === "nsPref:changed" && changedPref === RUN_INTERVAL_PREF) {
|
||||
this.updateRunInterval();
|
||||
} else {
|
||||
log.debug(`Observer fired with unexpected pref change: ${action} ${changedPref}`);
|
||||
}
|
||||
},
|
||||
|
||||
updateRunInterval() {
|
||||
// Run once every `runInterval` wall-clock seconds. This is managed by setting a "last ran"
|
||||
// timestamp, and running if it is more than `runInterval` seconds ago. Even with very short
|
||||
// intervals, the timer will only fire at most once every few minutes.
|
||||
const runInterval = prefs.getIntPref(RUN_INTERVAL_PREF);
|
||||
timerManager.registerTimer(TIMER_NAME, () => this.run(), runInterval);
|
||||
},
|
||||
|
||||
async run() {
|
||||
this.clearCaches();
|
||||
// Unless lazy classification is enabled, prep the classify cache.
|
||||
if (!Preferences.get("extensions.shield-recipe-client.experiments.lazy_classify", false)) {
|
||||
yield ClientEnvironment.getClientClassification();
|
||||
await ClientEnvironment.getClientClassification();
|
||||
}
|
||||
|
||||
const actionSandboxManagers = await this.loadActionSandboxManagers();
|
||||
Object.values(actionSandboxManagers).forEach(manager => manager.addHold("recipeRunner"));
|
||||
|
||||
// Run pre-execution hooks. If a hook fails, we don't run recipes with that
|
||||
// action to avoid inconsistencies.
|
||||
for (const [actionName, manager] of Object.entries(actionSandboxManagers)) {
|
||||
try {
|
||||
await manager.runAsyncCallback("preExecution");
|
||||
manager.disabled = false;
|
||||
} catch (err) {
|
||||
log.error(`Could not run pre-execution hook for ${actionName}:`, err.message);
|
||||
manager.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recipes from the API
|
||||
let recipes;
|
||||
try {
|
||||
recipes = yield NormandyApi.fetchRecipes({enabled: true});
|
||||
recipes = await NormandyApi.fetchRecipes({enabled: true});
|
||||
} catch (e) {
|
||||
const apiUrl = prefs.getCharPref("api_url");
|
||||
log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Evaluate recipe filters
|
||||
const recipesToRun = [];
|
||||
|
||||
for (const recipe of recipes) {
|
||||
if (yield this.checkFilter(recipe)) {
|
||||
if (await this.checkFilter(recipe)) {
|
||||
recipesToRun.push(recipe);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute recipes, if we have any.
|
||||
if (recipesToRun.length === 0) {
|
||||
log.debug("No recipes to execute");
|
||||
} else {
|
||||
for (const recipe of recipesToRun) {
|
||||
const manager = actionSandboxManagers[recipe.action];
|
||||
if (!manager) {
|
||||
log.error(
|
||||
`Could not execute recipe ${recipe.name}:`,
|
||||
`Action ${recipe.action} is either missing or invalid.`
|
||||
);
|
||||
} else if (manager.disabled) {
|
||||
log.warn(
|
||||
`Skipping recipe ${recipe.name} because ${recipe.action} failed during pre-execution.`
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
log.debug(`Executing recipe "${recipe.name}" (action=${recipe.action})`);
|
||||
yield this.executeRecipe(recipe);
|
||||
log.info(`Executing recipe "${recipe.name}" (action=${recipe.action})`);
|
||||
await manager.runAsyncCallback("action", recipe);
|
||||
} catch (e) {
|
||||
log.error(`Could not execute recipe ${recipe.name}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
getFilterContext() {
|
||||
// Run post-execution hooks
|
||||
for (const [actionName, manager] of Object.entries(actionSandboxManagers)) {
|
||||
// Skip if pre-execution failed.
|
||||
if (manager.disabled) {
|
||||
log.info(`Skipping post-execution hook for ${actionName} due to earlier failure.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await manager.runAsyncCallback("postExecution");
|
||||
} catch (err) {
|
||||
log.info(`Could not run post-execution hook for ${actionName}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Nuke sandboxes
|
||||
Object.values(actionSandboxManagers).forEach(manager => manager.removeHold("recipeRunner"));
|
||||
},
|
||||
|
||||
async loadActionSandboxManagers() {
|
||||
const actions = await NormandyApi.fetchActions();
|
||||
const actionSandboxManagers = {};
|
||||
for (const action of actions) {
|
||||
try {
|
||||
const implementation = await NormandyApi.fetchImplementation(action);
|
||||
actionSandboxManagers[action.name] = new ActionSandboxManager(implementation);
|
||||
} catch (err) {
|
||||
log.warn(`Could not fetch implementation for ${action.name}:`, err);
|
||||
}
|
||||
}
|
||||
return actionSandboxManagers;
|
||||
},
|
||||
|
||||
getFilterContext(recipe) {
|
||||
return {
|
||||
normandy: ClientEnvironment.getEnvironment(),
|
||||
normandy: Object.assign(ClientEnvironment.getEnvironment(), {
|
||||
recipe: {
|
||||
id: recipe.id,
|
||||
arguments: recipe.arguments,
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -113,10 +215,10 @@ this.RecipeRunner = {
|
|||
* @return {boolean} The result of evaluating the filter, cast to a bool, or false
|
||||
* if an error occurred during evaluation.
|
||||
*/
|
||||
checkFilter: Task.async(function* (recipe) {
|
||||
const context = this.getFilterContext();
|
||||
async checkFilter(recipe) {
|
||||
const context = this.getFilterContext(recipe);
|
||||
try {
|
||||
const result = yield FilterExpressions.eval(recipe.filter_expression, context);
|
||||
const result = await FilterExpressions.eval(recipe.filter_expression, context);
|
||||
return !!result;
|
||||
} catch (err) {
|
||||
log.error(`Error checking filter for "${recipe.name}"`);
|
||||
|
@ -124,69 +226,15 @@ this.RecipeRunner = {
|
|||
log.error(`Error: "${err}"`);
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
},
|
||||
|
||||
/**
|
||||
* Execute a recipe by fetching it action and executing it.
|
||||
* @param {Object} recipe A recipe to execute
|
||||
* @promise Resolves when the action has executed
|
||||
* Clear all caches of systems used by RecipeRunner, in preparation
|
||||
* for a clean run.
|
||||
*/
|
||||
executeRecipe: Task.async(function* (recipe) {
|
||||
const action = yield NormandyApi.fetchAction(recipe.action);
|
||||
const response = yield fetch(action.implementation_url);
|
||||
|
||||
const actionScript = yield response.text();
|
||||
yield this.executeAction(recipe, actionScript);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Execute an action in a sandbox for a specific recipe.
|
||||
* @param {Object} recipe A recipe to execute
|
||||
* @param {String} actionScript The JavaScript for the action to execute.
|
||||
* @promise Resolves or rejects when the action has executed or failed.
|
||||
*/
|
||||
executeAction(recipe, actionScript) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sandboxManager = new SandboxManager();
|
||||
const prepScript = `
|
||||
function registerAction(name, Action) {
|
||||
let a = new Action(sandboxedDriver, sandboxedRecipe);
|
||||
a.execute()
|
||||
.then(actionFinished)
|
||||
.catch(actionFailed);
|
||||
};
|
||||
|
||||
this.window = this;
|
||||
this.registerAction = registerAction;
|
||||
this.setTimeout = sandboxedDriver.setTimeout;
|
||||
this.clearTimeout = sandboxedDriver.clearTimeout;
|
||||
`;
|
||||
|
||||
const driver = new NormandyDriver(sandboxManager);
|
||||
sandboxManager.cloneIntoGlobal("sandboxedDriver", driver, {cloneFunctions: true});
|
||||
sandboxManager.cloneIntoGlobal("sandboxedRecipe", recipe);
|
||||
|
||||
// Results are cloned so that they don't become inaccessible when
|
||||
// the sandbox they came from is nuked when the hold is removed.
|
||||
sandboxManager.addGlobal("actionFinished", result => {
|
||||
const clonedResult = Cu.cloneInto(result, {});
|
||||
sandboxManager.removeHold("recipeExecution");
|
||||
resolve(clonedResult);
|
||||
});
|
||||
sandboxManager.addGlobal("actionFailed", err => {
|
||||
Cu.reportError(err);
|
||||
|
||||
// Error objects can't be cloned, so we just copy the message
|
||||
// (which doesn't need to be cloned) to be somewhat useful.
|
||||
const message = err.message;
|
||||
sandboxManager.removeHold("recipeExecution");
|
||||
reject(new Error(message));
|
||||
});
|
||||
|
||||
sandboxManager.addHold("recipeExecution");
|
||||
sandboxManager.evalInSandbox(prepScript);
|
||||
sandboxManager.evalInSandbox(actionScript);
|
||||
});
|
||||
clearCaches() {
|
||||
ClientEnvironment.clearClassifyCache();
|
||||
NormandyApi.clearIndexCache();
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -194,18 +242,17 @@ this.RecipeRunner = {
|
|||
* API url. This is used mainly by the mock-recipe-server JS that is
|
||||
* executed in the browser console.
|
||||
*/
|
||||
testRun: Task.async(function* (baseApiUrl) {
|
||||
async testRun(baseApiUrl) {
|
||||
const oldApiUrl = prefs.getCharPref("api_url");
|
||||
prefs.setCharPref("api_url", baseApiUrl);
|
||||
|
||||
try {
|
||||
Storage.clearAllStorage();
|
||||
ClientEnvironment.clearClassifyCache();
|
||||
NormandyApi.clearIndexCache();
|
||||
yield this.start();
|
||||
this.clearCaches();
|
||||
await this.run();
|
||||
} finally {
|
||||
prefs.setCharPref("api_url", oldApiUrl);
|
||||
NormandyApi.clearIndexCache();
|
||||
this.clearCaches();
|
||||
}
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
"use strict";
|
||||
|
||||
const {utils: Cu} = Components;
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
Cu.importGlobalProperties(["crypto", "TextEncoder"]);
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["Sampling"];
|
||||
|
@ -73,14 +72,14 @@ this.Sampling = {
|
|||
/**
|
||||
* @promise A hash of `data`, truncated to the 12 most significant characters.
|
||||
*/
|
||||
truncatedHash: Task.async(function* (data) {
|
||||
async truncatedHash(data) {
|
||||
const hasher = crypto.subtle;
|
||||
const input = new TextEncoder("utf-8").encode(JSON.stringify(data));
|
||||
const hash = yield hasher.digest("SHA-256", input);
|
||||
const hash = await hasher.digest("SHA-256", input);
|
||||
// truncate hash to 12 characters (2^48), because the full hash is larger
|
||||
// than JS can meaningfully represent as a number.
|
||||
return Sampling.bufferToHex(hash).slice(0, 12);
|
||||
}),
|
||||
},
|
||||
|
||||
/**
|
||||
* Sample by splitting the input into two buckets, one with a size (rate) and
|
||||
|
@ -92,12 +91,12 @@ this.Sampling = {
|
|||
* 0.25 returns true 25% of the time.
|
||||
* @promises {boolean} True if the input is in the sample.
|
||||
*/
|
||||
stableSample: Task.async(function* (input, rate) {
|
||||
const inputHash = yield Sampling.truncatedHash(input);
|
||||
async stableSample(input, rate) {
|
||||
const inputHash = await Sampling.truncatedHash(input);
|
||||
const samplePoint = Sampling.fractionToKey(rate);
|
||||
|
||||
return inputHash < samplePoint;
|
||||
}),
|
||||
},
|
||||
|
||||
/**
|
||||
* Sample by splitting the input space into a series of buckets, and checking
|
||||
|
@ -114,8 +113,8 @@ this.Sampling = {
|
|||
* @param {integer} total Total number of buckets to group inputs into.
|
||||
* @promises {boolean} True if the given input is within the range of buckets
|
||||
* we're checking. */
|
||||
bucketSample: Task.async(function* (input, start, count, total) {
|
||||
const inputHash = yield Sampling.truncatedHash(input);
|
||||
async bucketSample(input, start, count, total) {
|
||||
const inputHash = await Sampling.truncatedHash(input);
|
||||
const wrappedStart = start % total;
|
||||
const end = wrappedStart + count;
|
||||
|
||||
|
@ -129,5 +128,46 @@ this.Sampling = {
|
|||
}
|
||||
|
||||
return Sampling.isHashInBucket(inputHash, wrappedStart, end, total);
|
||||
}),
|
||||
},
|
||||
|
||||
/**
|
||||
* Sample over a list of ratios such that, over the input space, each ratio
|
||||
* has a number of matches in correct proportion to the other ratios.
|
||||
*
|
||||
* For example, given the ratios:
|
||||
*
|
||||
* [1, 2, 3, 4]
|
||||
*
|
||||
* 10% of all inputs will return 0, 20% of all inputs will return 1, 30% will
|
||||
* return 2, and 40% will return 3. You can determine the percent of inputs
|
||||
* that will return an index by dividing the ratio by the sum of all ratios
|
||||
* passed in. In the case above, 4 / (1 + 2 + 3 + 4) == 0.4, or 40% of the
|
||||
* inputs.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {Array<integer>} ratios
|
||||
* @promises {integer}
|
||||
* Index of the ratio that matched the input
|
||||
* @rejects {Error}
|
||||
* If the list of ratios doesn't have at least one element
|
||||
*/
|
||||
async ratioSample(input, ratios) {
|
||||
if (ratios.length < 1) {
|
||||
throw new Error(`ratios must be at least 1 element long (got length: ${ratios.length})`);
|
||||
}
|
||||
|
||||
const inputHash = await Sampling.truncatedHash(input);
|
||||
const ratioTotal = ratios.reduce((acc, ratio) => acc + ratio);
|
||||
|
||||
let samplePoint = 0;
|
||||
for (let k = 0; k < ratios.length - 1; k++) {
|
||||
samplePoint += ratios[k];
|
||||
if (inputHash <= Sampling.fractionToKey(samplePoint / ratioTotal)) {
|
||||
return k;
|
||||
}
|
||||
}
|
||||
|
||||
// No need to check the last bucket if the others didn't match.
|
||||
return ratios.length - 1;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -3,9 +3,20 @@ Cu.import("resource://gre/modules/Services.jsm");
|
|||
|
||||
this.EXPORTED_SYMBOLS = ["SandboxManager"];
|
||||
|
||||
/**
|
||||
* A wrapper class with helper methods for manipulating a sandbox.
|
||||
*
|
||||
* Along with convenient utility methods, SandboxManagers maintain a list of
|
||||
* "holds", which prevent the sandbox from being nuked until all registered
|
||||
* holds are removed. This allows sandboxes to trigger async operations and
|
||||
* automatically nuke themselves when they're done.
|
||||
*/
|
||||
this.SandboxManager = class {
|
||||
constructor() {
|
||||
this._sandbox = makeSandbox();
|
||||
this._sandbox = new Cu.Sandbox(null, {
|
||||
wantComponents: false,
|
||||
wantGlobalProperties: ["URL", "URLSearchParams"],
|
||||
});
|
||||
this.holds = [];
|
||||
}
|
||||
|
||||
|
@ -65,14 +76,46 @@ this.SandboxManager = class {
|
|||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps a function that returns a Promise from a privileged (i.e. chrome)
|
||||
* context and returns a Promise from this SandboxManager's sandbox. Useful
|
||||
* for exposing privileged functions to the sandbox, since the sandbox can't
|
||||
* access properties on privileged objects, e.g. Promise.then on a privileged
|
||||
* Promise.
|
||||
* @param {Function} wrappedFunction
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.cloneInto=false]
|
||||
* If true, the value resolved by the privileged Promise is cloned into the
|
||||
* sandbox before being resolved by the sandbox Promise. Without this, the
|
||||
* result will be Xray-wrapped.
|
||||
* @param {boolean} [options.cloneArguments=false]
|
||||
* If true, the arguments passed to wrappedFunction will be cloned into the
|
||||
* privileged chrome context. If wrappedFunction holds a reference to any of
|
||||
* its arguments, you will need this to avoid losing access to the arguments
|
||||
* when the sandbox they originate from is nuked.
|
||||
* @return {Function}
|
||||
*/
|
||||
wrapAsync(wrappedFunction, options = {cloneInto: false, cloneArguments: false}) {
|
||||
// In order for `this` to work in wrapped functions, we must return a
|
||||
// non-arrow function, which requires saving a reference to the manager.
|
||||
const sandboxManager = this;
|
||||
return function(...args) {
|
||||
return new sandboxManager.sandbox.Promise((resolve, reject) => {
|
||||
if (options.cloneArguments) {
|
||||
args = Cu.cloneInto(args, {});
|
||||
}
|
||||
|
||||
function makeSandbox() {
|
||||
const sandbox = new Cu.Sandbox(null, {
|
||||
wantComponents: false,
|
||||
wantGlobalProperties: ["URL", "URLSearchParams"],
|
||||
wrappedFunction.apply(this, args).then(result => {
|
||||
if (options.cloneInto) {
|
||||
result = sandboxManager.cloneInto(result);
|
||||
}
|
||||
|
||||
resolve(result);
|
||||
}, err => {
|
||||
reject(new sandboxManager.sandbox.Error(err.message, err.fileName, err.lineNumber));
|
||||
});
|
||||
|
||||
return sandbox;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
"use strict";
|
||||
|
||||
const {utils: Cu} = Components;
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "LogManager",
|
||||
"resource://shield-recipe-client/lib/LogManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "RecipeRunner",
|
||||
"resource://shield-recipe-client/lib/RecipeRunner.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "CleanupManager",
|
||||
"resource://shield-recipe-client/lib/CleanupManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "PreferenceExperiments",
|
||||
"resource://shield-recipe-client/lib/PreferenceExperiments.jsm");
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["ShieldRecipeClient"];
|
||||
|
||||
const REASONS = {
|
||||
APP_STARTUP: 1, // The application is starting up.
|
||||
APP_SHUTDOWN: 2, // The application is shutting down.
|
||||
ADDON_ENABLE: 3, // The add-on is being enabled.
|
||||
ADDON_DISABLE: 4, // The add-on is being disabled. (Also sent during uninstallation)
|
||||
ADDON_INSTALL: 5, // The add-on is being installed.
|
||||
ADDON_UNINSTALL: 6, // The add-on is being uninstalled.
|
||||
ADDON_UPGRADE: 7, // The add-on is being upgraded.
|
||||
ADDON_DOWNGRADE: 8, // The add-on is being downgraded.
|
||||
};
|
||||
const PREF_BRANCH = "extensions.shield-recipe-client.";
|
||||
const DEFAULT_PREFS = {
|
||||
api_url: "https://normandy.cdn.mozilla.net/api/v1",
|
||||
dev_mode: false,
|
||||
enabled: true,
|
||||
startup_delay_seconds: 300,
|
||||
"logging.level": Log.Level.Warn,
|
||||
user_id: "",
|
||||
run_interval_seconds: 86400, // 24 hours
|
||||
};
|
||||
const PREF_DEV_MODE = "extensions.shield-recipe-client.dev_mode";
|
||||
const PREF_SELF_SUPPORT_ENABLED = "browser.selfsupport.enabled";
|
||||
const PREF_LOGGING_LEVEL = PREF_BRANCH + "logging.level";
|
||||
|
||||
let log = null;
|
||||
|
||||
/**
|
||||
* Handles startup and shutdown of the entire add-on. Bootsrap.js defers to this
|
||||
* module for most tasks so that we can more easily test startup and shutdown
|
||||
* (bootstrap.js is difficult to import in tests).
|
||||
*/
|
||||
this.ShieldRecipeClient = {
|
||||
async startup() {
|
||||
ShieldRecipeClient.setDefaultPrefs();
|
||||
|
||||
// Setup logging and listen for changes to logging prefs
|
||||
LogManager.configure(Services.prefs.getIntPref(PREF_LOGGING_LEVEL));
|
||||
Preferences.observe(PREF_LOGGING_LEVEL, LogManager.configure);
|
||||
CleanupManager.addCleanupHandler(
|
||||
() => Preferences.ignore(PREF_LOGGING_LEVEL, LogManager.configure),
|
||||
);
|
||||
log = LogManager.getLogger("bootstrap");
|
||||
|
||||
// Disable self-support, since we replace its behavior.
|
||||
// Self-support only checks its pref on start, so if we disable it, wait
|
||||
// until next startup to run, unless the dev_mode preference is set.
|
||||
if (Preferences.get(PREF_SELF_SUPPORT_ENABLED, true)) {
|
||||
Preferences.set(PREF_SELF_SUPPORT_ENABLED, false);
|
||||
if (!Preferences.get(PREF_DEV_MODE, false)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize experiments first to avoid a race between initializing prefs
|
||||
// and recipes rolling back pref changes when experiments end.
|
||||
try {
|
||||
await PreferenceExperiments.init();
|
||||
} catch (err) {
|
||||
log.error("Failed to initialize preference experiments:", err);
|
||||
}
|
||||
|
||||
await RecipeRunner.init();
|
||||
},
|
||||
|
||||
shutdown(reason) {
|
||||
CleanupManager.cleanup();
|
||||
|
||||
// Re-enable self-support if we're being disabled.
|
||||
if (reason === REASONS.ADDON_DISABLE || reason === REASONS.ADDON_UNINSTALL) {
|
||||
Services.prefs.setBoolPref(PREF_SELF_SUPPORT_ENABLED, true);
|
||||
}
|
||||
},
|
||||
|
||||
setDefaultPrefs() {
|
||||
for (const [key, val] of Object.entries(DEFAULT_PREFS)) {
|
||||
const fullKey = PREF_BRANCH + key;
|
||||
// If someone beat us to setting a default, don't overwrite it.
|
||||
if (!Preferences.isSet(fullKey)) {
|
||||
Preferences.set(fullKey, val);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
|
@ -10,7 +10,6 @@ Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
|
|||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "JSONFile", "resource://gre/modules/JSONFile.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm");
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["Storage"];
|
||||
|
||||
|
@ -21,10 +20,10 @@ function loadStorage() {
|
|||
if (storePromise === undefined) {
|
||||
const path = OS.Path.join(OS.Constants.Path.profileDir, "shield-recipe-client.json");
|
||||
const storage = new JSONFile({path});
|
||||
storePromise = Task.spawn(function* () {
|
||||
yield storage.load();
|
||||
storePromise = (async function() {
|
||||
await storage.load();
|
||||
return storage;
|
||||
});
|
||||
})();
|
||||
}
|
||||
return storePromise;
|
||||
}
|
||||
|
@ -70,7 +69,7 @@ this.Storage = {
|
|||
if (!(prefix in store.data)) {
|
||||
store.data[prefix] = {};
|
||||
}
|
||||
store.data[prefix][keySuffix] = value;
|
||||
store.data[prefix][keySuffix] = Cu.cloneInto(value, {});
|
||||
store.saveSoon();
|
||||
resolve();
|
||||
})
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use strict";
|
||||
|
||||
const {utils: Cu} = Components;
|
||||
Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["Utils"];
|
||||
|
||||
const log = LogManager.getLogger("utils");
|
||||
|
||||
this.Utils = {
|
||||
/**
|
||||
* Convert an array of objects to an object. Each item is keyed by the value
|
||||
* of the given key on the item.
|
||||
*
|
||||
* > list = [{foo: "bar"}, {foo: "baz"}]
|
||||
* > keyBy(list, "foo") == {bar: {foo: "bar"}, baz: {foo: "baz"}}
|
||||
*
|
||||
* @param {Array} list
|
||||
* @param {String} key
|
||||
* @return {Object}
|
||||
*/
|
||||
keyBy(list, key) {
|
||||
return list.reduce((map, item) => {
|
||||
if (!(key in item)) {
|
||||
log.warn(`Skipping list due to missing key "${key}".`);
|
||||
return map;
|
||||
}
|
||||
|
||||
map[item[key]] = item;
|
||||
return map;
|
||||
}, {});
|
||||
},
|
||||
};
|
|
@ -4,9 +4,6 @@
|
|||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
with Files("**"):
|
||||
BUG_COMPONENT = ("Shield", "Add-on")
|
||||
|
||||
DEFINES['MOZ_APP_VERSION'] = CONFIG['MOZ_APP_VERSION']
|
||||
DEFINES['MOZ_APP_MAXVERSION'] = CONFIG['MOZ_APP_MAXVERSION']
|
||||
|
||||
|
|
|
@ -5,7 +5,8 @@ module.exports = {
|
|||
Assert: false,
|
||||
add_task: false,
|
||||
getRootDirectory: false,
|
||||
gTestPath: false
|
||||
gTestPath: false,
|
||||
Cu: false,
|
||||
},
|
||||
rules: {
|
||||
"spaced-comment": 2,
|
||||
|
|
|
@ -17,5 +17,7 @@ module.exports = {
|
|||
UUID_REGEX: false,
|
||||
withSandboxManager: false,
|
||||
withDriver: false,
|
||||
withMockNormandyApi: false,
|
||||
withMockPreferences: false,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
// Returns JS for an action, regardless of the URL.
|
||||
function handleRequest(request, response) {
|
||||
// Allow cross-origin, so you can XHR to it!
|
||||
response.setHeader("Access-Control-Allow-Origin", "*", false);
|
||||
// Avoid confusing cache behaviors
|
||||
response.setHeader("Cache-Control", "no-cache", false);
|
||||
|
||||
// Write response body
|
||||
response.write('registerAsyncCallback("action", async () => {});');
|
||||
}
|
|
@ -6,5 +6,9 @@ head = head.js
|
|||
[browser_Storage.js]
|
||||
[browser_Heartbeat.js]
|
||||
[browser_RecipeRunner.js]
|
||||
support-files =
|
||||
action_server.sjs
|
||||
[browser_LogManager.js]
|
||||
[browser_ClientEnvironment.js]
|
||||
[browser_ShieldRecipeClient.js]
|
||||
[browser_PreferenceExperiments.js]
|
||||
|
|
|
@ -3,69 +3,68 @@
|
|||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://gre/modules/TelemetryController.jsm", this);
|
||||
Cu.import("resource://gre/modules/Task.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/ClientEnvironment.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/PreferenceExperiments.jsm", this);
|
||||
|
||||
|
||||
add_task(function* testTelemetry() {
|
||||
add_task(async function testTelemetry() {
|
||||
// setup
|
||||
yield TelemetryController.submitExternalPing("testfoo", {foo: 1});
|
||||
yield TelemetryController.submitExternalPing("testbar", {bar: 2});
|
||||
await TelemetryController.submitExternalPing("testfoo", {foo: 1});
|
||||
await TelemetryController.submitExternalPing("testbar", {bar: 2});
|
||||
const environment = ClientEnvironment.getEnvironment();
|
||||
|
||||
// Test it can access telemetry
|
||||
const telemetry = yield environment.telemetry;
|
||||
is(typeof telemetry, "object", "Telemetry is accessible");
|
||||
const telemetry = await environment.telemetry;
|
||||
is(typeof telemetry, "object", "Telemetry is accesible");
|
||||
|
||||
// Test it reads different types of telemetry
|
||||
is(telemetry.testfoo.payload.foo, 1, "value 'foo' is in mock telemetry");
|
||||
is(telemetry.testbar.payload.bar, 2, "value 'bar' is in mock telemetry");
|
||||
});
|
||||
|
||||
add_task(function* testUserId() {
|
||||
add_task(async function testUserId() {
|
||||
let environment = ClientEnvironment.getEnvironment();
|
||||
|
||||
// Test that userId is available
|
||||
ok(UUID_REGEX.test(environment.userId), "userId available");
|
||||
|
||||
// test that it pulls from the right preference
|
||||
yield SpecialPowers.pushPrefEnv({set: [["extensions.shield-recipe-client.user_id", "fake id"]]});
|
||||
await SpecialPowers.pushPrefEnv({set: [["extensions.shield-recipe-client.user_id", "fake id"]]});
|
||||
environment = ClientEnvironment.getEnvironment();
|
||||
is(environment.userId, "fake id", "userId is pulled from preferences");
|
||||
});
|
||||
|
||||
add_task(function* testDistribution() {
|
||||
add_task(async function testDistribution() {
|
||||
let environment = ClientEnvironment.getEnvironment();
|
||||
|
||||
// distribution id defaults to "default"
|
||||
is(environment.distribution, "default", "distribution has a default value");
|
||||
|
||||
// distribution id is read from a preference
|
||||
yield SpecialPowers.pushPrefEnv({set: [["distribution.id", "funnelcake"]]});
|
||||
await SpecialPowers.pushPrefEnv({set: [["distribution.id", "funnelcake"]]});
|
||||
environment = ClientEnvironment.getEnvironment();
|
||||
is(environment.distribution, "funnelcake", "distribution is read from preferences");
|
||||
});
|
||||
|
||||
const mockClassify = {country: "FR", request_time: new Date(2017, 1, 1)};
|
||||
add_task(ClientEnvironment.withMockClassify(mockClassify, function* testCountryRequestTime() {
|
||||
add_task(ClientEnvironment.withMockClassify(mockClassify, async function testCountryRequestTime() {
|
||||
const environment = ClientEnvironment.getEnvironment();
|
||||
|
||||
// Test that country and request_time pull their data from the server.
|
||||
is(yield environment.country, mockClassify.country, "country is read from the server API");
|
||||
is(await environment.country, mockClassify.country, "country is read from the server API");
|
||||
is(
|
||||
yield environment.request_time, mockClassify.request_time,
|
||||
await environment.request_time, mockClassify.request_time,
|
||||
"request_time is read from the server API"
|
||||
);
|
||||
}));
|
||||
|
||||
add_task(function* testSync() {
|
||||
add_task(async function testSync() {
|
||||
let environment = ClientEnvironment.getEnvironment();
|
||||
is(environment.syncMobileDevices, 0, "syncMobileDevices defaults to zero");
|
||||
is(environment.syncDesktopDevices, 0, "syncDesktopDevices defaults to zero");
|
||||
is(environment.syncTotalDevices, 0, "syncTotalDevices defaults to zero");
|
||||
yield SpecialPowers.pushPrefEnv({
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [
|
||||
["services.sync.numClients", 9],
|
||||
["services.sync.clients.devices.mobile", 5],
|
||||
["services.sync.clients.devices.desktop", 4],
|
||||
],
|
||||
|
@ -76,14 +75,40 @@ add_task(function* testSync() {
|
|||
is(environment.syncTotalDevices, 9, "syncTotalDevices is read when set");
|
||||
});
|
||||
|
||||
add_task(function* testDoNotTrack() {
|
||||
add_task(async function testDoNotTrack() {
|
||||
let environment = ClientEnvironment.getEnvironment();
|
||||
|
||||
// doNotTrack defaults to false
|
||||
ok(!environment.doNotTrack, "doNotTrack has a default value");
|
||||
|
||||
// doNotTrack is read from a preference
|
||||
yield SpecialPowers.pushPrefEnv({set: [["privacy.donottrackheader.enabled", true]]});
|
||||
await SpecialPowers.pushPrefEnv({set: [["privacy.donottrackheader.enabled", true]]});
|
||||
environment = ClientEnvironment.getEnvironment();
|
||||
ok(environment.doNotTrack, "doNotTrack is read from preferences");
|
||||
});
|
||||
|
||||
add_task(async function testExperiments() {
|
||||
const active = {name: "active", expired: false};
|
||||
const expired = {name: "expired", expired: true};
|
||||
const getAll = sinon.stub(PreferenceExperiments, "getAll", async () => [active, expired]);
|
||||
|
||||
const environment = ClientEnvironment.getEnvironment();
|
||||
const experiments = await environment.experiments;
|
||||
Assert.deepEqual(
|
||||
experiments.all,
|
||||
["active", "expired"],
|
||||
"experiments.all returns all stored experiment names",
|
||||
);
|
||||
Assert.deepEqual(
|
||||
experiments.active,
|
||||
["active"],
|
||||
"experiments.active returns all active experiment names",
|
||||
);
|
||||
Assert.deepEqual(
|
||||
experiments.expired,
|
||||
["expired"],
|
||||
"experiments.expired returns all expired experiment names",
|
||||
);
|
||||
|
||||
getAll.restore();
|
||||
});
|
||||
|
|
|
@ -26,7 +26,7 @@ function listenerC(x = 1) {
|
|||
evidence.log += "c";
|
||||
}
|
||||
|
||||
add_task(withSandboxManager(Assert, function* (sandboxManager) {
|
||||
add_task(withSandboxManager(Assert, async function(sandboxManager) {
|
||||
const eventEmitter = new EventEmitter(sandboxManager);
|
||||
|
||||
// Fire an unrelated event, to make sure nothing goes wrong
|
||||
|
@ -51,7 +51,7 @@ add_task(withSandboxManager(Assert, function* (sandboxManager) {
|
|||
}, "events are fired async");
|
||||
|
||||
// Spin the event loop to run events, so we can safely "off"
|
||||
yield Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// Check intermediate event results
|
||||
Assert.deepEqual(evidence, {
|
||||
|
@ -69,7 +69,7 @@ add_task(withSandboxManager(Assert, function* (sandboxManager) {
|
|||
eventEmitter.on("nothing");
|
||||
|
||||
// Spin the event loop to run events
|
||||
yield Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
Assert.deepEqual(evidence, {
|
||||
a: 111,
|
||||
|
@ -91,13 +91,13 @@ add_task(withSandboxManager(Assert, function* (sandboxManager) {
|
|||
|
||||
const data = {count: 0};
|
||||
eventEmitter.emit("mutationTest", data);
|
||||
yield Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
is(handlerRunCount, 2, "Mutation handler was executed twice.");
|
||||
is(data.count, 0, "Event data cannot be mutated by handlers.");
|
||||
}));
|
||||
|
||||
add_task(withSandboxManager(Assert, function* sandboxedEmitter(sandboxManager) {
|
||||
add_task(withSandboxManager(Assert, async function sandboxedEmitter(sandboxManager) {
|
||||
const eventEmitter = new EventEmitter(sandboxManager);
|
||||
|
||||
// Event handlers inside the sandbox should be run in response to
|
||||
|
@ -117,7 +117,7 @@ add_task(withSandboxManager(Assert, function* sandboxedEmitter(sandboxManager) {
|
|||
eventEmitter.emit("event", 10);
|
||||
eventEmitter.emit("eventOnce", 5);
|
||||
eventEmitter.emit("eventOnce", 10);
|
||||
yield Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const eventCounts = sandboxManager.evalInSandbox("this.eventCounts");
|
||||
Assert.deepEqual(eventCounts, {
|
||||
|
|
|
@ -2,53 +2,92 @@
|
|||
|
||||
Cu.import("resource://shield-recipe-client/lib/FilterExpressions.jsm", this);
|
||||
|
||||
add_task(function* () {
|
||||
// Basic JEXL tests
|
||||
add_task(async function() {
|
||||
let val;
|
||||
// Test that basic expressions work
|
||||
val = yield FilterExpressions.eval("2+2");
|
||||
val = await FilterExpressions.eval("2+2");
|
||||
is(val, 4, "basic expression works");
|
||||
|
||||
// Test that multiline expressions work
|
||||
val = yield FilterExpressions.eval(`
|
||||
val = await FilterExpressions.eval(`
|
||||
2
|
||||
+
|
||||
2
|
||||
`);
|
||||
is(val, 4, "multiline expression works");
|
||||
|
||||
// Test that it reads from the context correctly.
|
||||
val = await FilterExpressions.eval("first + second + 3", {first: 1, second: 2});
|
||||
is(val, 6, "context is available to filter expressions");
|
||||
});
|
||||
|
||||
// Date tests
|
||||
add_task(async function() {
|
||||
let val;
|
||||
// Test has a date transform
|
||||
val = yield FilterExpressions.eval('"2016-04-22"|date');
|
||||
val = await FilterExpressions.eval('"2016-04-22"|date');
|
||||
const d = new Date(Date.UTC(2016, 3, 22)); // months are 0 based
|
||||
is(val.toString(), d.toString(), "Date transform works");
|
||||
|
||||
// Test dates are comparable
|
||||
const context = {someTime: Date.UTC(2016, 0, 1)};
|
||||
val = yield FilterExpressions.eval('"2015-01-01"|date < someTime', context);
|
||||
val = await FilterExpressions.eval('"2015-01-01"|date < someTime', context);
|
||||
ok(val, "dates are comparable with less-than");
|
||||
val = yield FilterExpressions.eval('"2017-01-01"|date > someTime', context);
|
||||
val = await FilterExpressions.eval('"2017-01-01"|date > someTime', context);
|
||||
ok(val, "dates are comparable with greater-than");
|
||||
});
|
||||
|
||||
// Sampling tests
|
||||
add_task(async function() {
|
||||
let val;
|
||||
// Test stable sample returns true for matching samples
|
||||
val = await FilterExpressions.eval('["test"]|stableSample(1)');
|
||||
ok(val, "Stable sample returns true for 100% sample");
|
||||
|
||||
// Test stable sample returns true for matching samples
|
||||
val = yield FilterExpressions.eval('["test"]|stableSample(1)');
|
||||
is(val, true, "Stable sample returns true for 100% sample");
|
||||
|
||||
// Test stable sample returns true for matching samples
|
||||
val = yield FilterExpressions.eval('["test"]|stableSample(0)');
|
||||
is(val, false, "Stable sample returns false for 0% sample");
|
||||
val = await FilterExpressions.eval('["test"]|stableSample(0)');
|
||||
ok(!val, "Stable sample returns false for 0% sample");
|
||||
|
||||
// Test stable sample for known samples
|
||||
val = yield FilterExpressions.eval('["test-1"]|stableSample(0.5)');
|
||||
is(val, true, "Stable sample returns true for a known sample");
|
||||
val = yield FilterExpressions.eval('["test-4"]|stableSample(0.5)');
|
||||
is(val, false, "Stable sample returns false for a known sample");
|
||||
val = await FilterExpressions.eval('["test-1"]|stableSample(0.5)');
|
||||
ok(val, "Stable sample returns true for a known sample");
|
||||
val = await FilterExpressions.eval('["test-4"]|stableSample(0.5)');
|
||||
ok(!val, "Stable sample returns false for a known sample");
|
||||
|
||||
// Test bucket sample for known samples
|
||||
val = yield FilterExpressions.eval('["test-1"]|bucketSample(0, 5, 10)');
|
||||
is(val, true, "Bucket sample returns true for a known sample");
|
||||
val = yield FilterExpressions.eval('["test-4"]|bucketSample(0, 5, 10)');
|
||||
is(val, false, "Bucket sample returns false for a known sample");
|
||||
|
||||
// Test that it reads from the context correctly.
|
||||
val = yield FilterExpressions.eval("first + second + 3", {first: 1, second: 2});
|
||||
is(val, 6, "context is available to filter expressions");
|
||||
val = await FilterExpressions.eval('["test-1"]|bucketSample(0, 5, 10)');
|
||||
ok(val, "Bucket sample returns true for a known sample");
|
||||
val = await FilterExpressions.eval('["test-4"]|bucketSample(0, 5, 10)');
|
||||
ok(!val, "Bucket sample returns false for a known sample");
|
||||
});
|
||||
|
||||
// Preference tests
|
||||
add_task(async function() {
|
||||
let val;
|
||||
// Compare the value of the preference
|
||||
await SpecialPowers.pushPrefEnv({set: [["normandy.test.value", 3]]});
|
||||
val = await FilterExpressions.eval('"normandy.test.value"|preferenceValue == 3');
|
||||
ok(val, "preferenceValue expression compares against preference values");
|
||||
val = await FilterExpressions.eval('"normandy.test.value"|preferenceValue == "test"');
|
||||
ok(!val, "preferenceValue expression fails value checks appropriately");
|
||||
|
||||
// preferenceValue can take a default value as an optional argument, which
|
||||
// defaults to `undefined`.
|
||||
val = await FilterExpressions.eval('"normandy.test.default"|preferenceValue(false) == false');
|
||||
ok(val, "preferenceValue takes optional 'default value' param for prefs without set values");
|
||||
val = await FilterExpressions.eval('"normandy.test.value"|preferenceValue(5) == 5');
|
||||
ok(!val, "preferenceValue default param is not returned for prefs with set values");
|
||||
|
||||
// Compare if the preference is user set
|
||||
val = await FilterExpressions.eval('"normandy.test.isSet"|preferenceIsUserSet == true');
|
||||
ok(!val, "preferenceIsUserSet expression determines if preference is set at all");
|
||||
val = await FilterExpressions.eval('"normandy.test.value"|preferenceIsUserSet == true');
|
||||
ok(val, "preferenceIsUserSet expression determines if user's preference has been set");
|
||||
|
||||
// Compare if the preference has _any_ value, whether it's user-set or default,
|
||||
val = await FilterExpressions.eval('"normandy.test.nonexistant"|preferenceExists == true');
|
||||
ok(!val, "preferenceExists expression determines if preference exists at all");
|
||||
val = await FilterExpressions.eval('"normandy.test.value"|preferenceExists == true');
|
||||
ok(val, "preferenceExists expression fails existence check appropriately");
|
||||
});
|
||||
|
|
|
@ -77,7 +77,7 @@ sandboxManager.addHold("test running");
|
|||
// into three batches.
|
||||
|
||||
/* Batch #1 - General UI, Stars, and telemetry data */
|
||||
add_task(function* () {
|
||||
add_task(async function() {
|
||||
const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
|
||||
const notificationBox = targetWindow.document.querySelector("#high-priority-global-notificationbox");
|
||||
|
||||
|
@ -104,22 +104,22 @@ add_task(function* () {
|
|||
// Check that when clicking the learn more link, a tab opens with the right URL
|
||||
const tabOpenPromise = BrowserTestUtils.waitForNewTab(targetWindow.gBrowser);
|
||||
learnMoreEl.click();
|
||||
const tab = yield tabOpenPromise;
|
||||
const tabUrl = yield BrowserTestUtils.browserLoaded(
|
||||
const tab = await tabOpenPromise;
|
||||
const tabUrl = await BrowserTestUtils.browserLoaded(
|
||||
tab.linkedBrowser, true, url => url && url !== "about:blank");
|
||||
|
||||
Assert.equal(tabUrl, "https://example.org/learnmore", "Learn more link opened the right url");
|
||||
|
||||
const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "learnMoreTS", "closedTS"]);
|
||||
// Close notification to trigger telemetry to be sent
|
||||
yield closeAllNotifications(targetWindow, notificationBox);
|
||||
yield telemetrySentPromise;
|
||||
yield BrowserTestUtils.removeTab(tab);
|
||||
await closeAllNotifications(targetWindow, notificationBox);
|
||||
await telemetrySentPromise;
|
||||
await BrowserTestUtils.removeTab(tab);
|
||||
});
|
||||
|
||||
|
||||
// Batch #2 - Engagement buttons
|
||||
add_task(function* () {
|
||||
add_task(async function() {
|
||||
const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
|
||||
const notificationBox = targetWindow.document.querySelector("#high-priority-global-notificationbox");
|
||||
const hb = new Heartbeat(targetWindow, sandboxManager, {
|
||||
|
@ -140,22 +140,22 @@ add_task(function* () {
|
|||
const engagementEl = hb.notice.querySelector(".notification-button");
|
||||
const tabOpenPromise = BrowserTestUtils.waitForNewTab(targetWindow.gBrowser);
|
||||
engagementEl.click();
|
||||
const tab = yield tabOpenPromise;
|
||||
const tabUrl = yield BrowserTestUtils.browserLoaded(
|
||||
const tab = await tabOpenPromise;
|
||||
const tabUrl = await BrowserTestUtils.browserLoaded(
|
||||
tab.linkedBrowser, true, url => url && url !== "about:blank");
|
||||
// the postAnswer url gets query parameters appended onto the end, so use Assert.startsWith instead of Assert.equal
|
||||
Assert.ok(tabUrl.startsWith("https://example.org/postAnswer"), "Engagement button opened the right url");
|
||||
|
||||
const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "engagedTS", "closedTS"]);
|
||||
// Close notification to trigger telemetry to be sent
|
||||
yield closeAllNotifications(targetWindow, notificationBox);
|
||||
yield telemetrySentPromise;
|
||||
yield BrowserTestUtils.removeTab(tab);
|
||||
await closeAllNotifications(targetWindow, notificationBox);
|
||||
await telemetrySentPromise;
|
||||
await BrowserTestUtils.removeTab(tab);
|
||||
});
|
||||
|
||||
// Batch 3 - Closing the window while heartbeat is open
|
||||
add_task(function* () {
|
||||
const targetWindow = yield BrowserTestUtils.openNewBrowserWindow();
|
||||
add_task(async function() {
|
||||
const targetWindow = await BrowserTestUtils.openNewBrowserWindow();
|
||||
|
||||
const hb = new Heartbeat(targetWindow, sandboxManager, {
|
||||
testing: true,
|
||||
|
@ -165,16 +165,16 @@ add_task(function* () {
|
|||
|
||||
const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "windowClosedTS"]);
|
||||
// triggers sending ping to normandy
|
||||
yield BrowserTestUtils.closeWindow(targetWindow);
|
||||
yield telemetrySentPromise;
|
||||
await BrowserTestUtils.closeWindow(targetWindow);
|
||||
await telemetrySentPromise;
|
||||
});
|
||||
|
||||
|
||||
// Cleanup
|
||||
add_task(function* () {
|
||||
add_task(async function() {
|
||||
// Make sure the sandbox is clean.
|
||||
sandboxManager.removeHold("test running");
|
||||
yield sandboxManager.isNuked()
|
||||
await sandboxManager.isNuked()
|
||||
.then(() => ok(true, "sandbox is nuked"))
|
||||
.catch(e => ok(false, "sandbox is nuked", e));
|
||||
});
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
Cu.import("resource://gre/modules/Log.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/LogManager.jsm", this);
|
||||
|
||||
add_task(function*() {
|
||||
add_task(async function() {
|
||||
// Ensure that configuring the logger affects all generated loggers.
|
||||
const firstLogger = LogManager.getLogger("first");
|
||||
LogManager.configure(5);
|
||||
|
@ -17,7 +17,7 @@ add_task(function*() {
|
|||
ok(logger.appenders.length > 0, true, "Loggers have at least one appender.");
|
||||
|
||||
// Ensure our loggers log to the console.
|
||||
yield new Promise(resolve => {
|
||||
await new Promise(resolve => {
|
||||
SimpleTest.waitForExplicitFinish();
|
||||
SimpleTest.monitorConsole(resolve, [{message: /legend has it/}]);
|
||||
logger.warn("legend has it");
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"use strict";
|
||||
|
||||
add_task(withDriver(Assert, function* uuids(driver) {
|
||||
add_task(withDriver(Assert, async function uuids(driver) {
|
||||
// Test that it is a UUID
|
||||
const uuid1 = driver.uuid();
|
||||
ok(UUID_REGEX.test(uuid1), "valid uuid format");
|
||||
|
@ -10,36 +10,35 @@ add_task(withDriver(Assert, function* uuids(driver) {
|
|||
isnot(uuid1, uuid2, "uuids are unique");
|
||||
}));
|
||||
|
||||
add_task(withDriver(Assert, function* userId(driver) {
|
||||
add_task(withDriver(Assert, async function userId(driver) {
|
||||
// Test that userId is a UUID
|
||||
ok(UUID_REGEX.test(driver.userId), "userId is a uuid");
|
||||
}));
|
||||
|
||||
add_task(withDriver(Assert, function* syncDeviceCounts(driver) {
|
||||
let client = yield driver.client();
|
||||
add_task(withDriver(Assert, async function syncDeviceCounts(driver) {
|
||||
let client = await driver.client();
|
||||
is(client.syncMobileDevices, 0, "syncMobileDevices defaults to zero");
|
||||
is(client.syncDesktopDevices, 0, "syncDesktopDevices defaults to zero");
|
||||
is(client.syncTotalDevices, 0, "syncTotalDevices defaults to zero");
|
||||
|
||||
yield SpecialPowers.pushPrefEnv({
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [
|
||||
["services.sync.numClients", 9],
|
||||
["services.sync.clients.devices.mobile", 5],
|
||||
["services.sync.clients.devices.desktop", 4],
|
||||
],
|
||||
});
|
||||
|
||||
client = yield driver.client();
|
||||
client = await driver.client();
|
||||
is(client.syncMobileDevices, 5, "syncMobileDevices is read when set");
|
||||
is(client.syncDesktopDevices, 4, "syncDesktopDevices is read when set");
|
||||
is(client.syncTotalDevices, 9, "syncTotalDevices is read when set");
|
||||
}));
|
||||
|
||||
add_task(withDriver(Assert, function* distribution(driver) {
|
||||
let client = yield driver.client();
|
||||
add_task(withDriver(Assert, async function distribution(driver) {
|
||||
let client = await driver.client();
|
||||
is(client.distribution, "default", "distribution has a default value");
|
||||
|
||||
yield SpecialPowers.pushPrefEnv({set: [["distribution.id", "funnelcake"]]});
|
||||
client = yield driver.client();
|
||||
await SpecialPowers.pushPrefEnv({set: [["distribution.id", "funnelcake"]]});
|
||||
client = await driver.client();
|
||||
is(client.distribution, "funnelcake", "distribution is read from preferences");
|
||||
}));
|
||||
|
|
|
@ -0,0 +1,662 @@
|
|||
"use strict";
|
||||
|
||||
Cu.import("resource://gre/modules/Preferences.jsm", this);
|
||||
Cu.import("resource://gre/modules/TelemetryEnvironment.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/PreferenceExperiments.jsm", this);
|
||||
|
||||
// Save ourselves some typing
|
||||
const {withMockExperiments} = PreferenceExperiments;
|
||||
const DefaultPreferences = new Preferences({defaultBranch: true});
|
||||
|
||||
function experimentFactory(attrs) {
|
||||
return Object.assign({
|
||||
name: "fakename",
|
||||
branch: "fakebranch",
|
||||
expired: false,
|
||||
lastSeen: new Date().toJSON(),
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "falkevalue",
|
||||
preferenceType: "string",
|
||||
previousPreferenceValue: "oldfakevalue",
|
||||
preferenceBranchType: "default",
|
||||
}, attrs);
|
||||
}
|
||||
|
||||
// clearAllExperimentStorage
|
||||
add_task(withMockExperiments(async function(experiments) {
|
||||
experiments["test"] = experimentFactory({name: "test"});
|
||||
ok(await PreferenceExperiments.has("test"), "Mock experiment is detected.");
|
||||
await PreferenceExperiments.clearAllExperimentStorage();
|
||||
ok(
|
||||
!(await PreferenceExperiments.has("test")),
|
||||
"clearAllExperimentStorage removed all stored experiments",
|
||||
);
|
||||
}));
|
||||
|
||||
// start should throw if an experiment with the given name already exists
|
||||
add_task(withMockExperiments(async function(experiments) {
|
||||
experiments["test"] = experimentFactory({name: "test"});
|
||||
await Assert.rejects(
|
||||
PreferenceExperiments.start({
|
||||
name: "test",
|
||||
branch: "branch",
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "value",
|
||||
preferenceType: "string",
|
||||
preferenceBranchType: "default",
|
||||
}),
|
||||
"start threw an error due to a conflicting experiment name",
|
||||
);
|
||||
}));
|
||||
|
||||
// start should throw if an experiment for the given preference is active
|
||||
add_task(withMockExperiments(async function(experiments) {
|
||||
experiments["test"] = experimentFactory({name: "test", preferenceName: "fake.preference"});
|
||||
await Assert.rejects(
|
||||
PreferenceExperiments.start({
|
||||
name: "different",
|
||||
branch: "branch",
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "value",
|
||||
preferenceType: "string",
|
||||
preferenceBranchType: "default",
|
||||
}),
|
||||
"start threw an error due to an active experiment for the given preference",
|
||||
);
|
||||
}));
|
||||
|
||||
// start should throw if an invalid preferenceBranchType is given
|
||||
add_task(withMockExperiments(async function() {
|
||||
await Assert.rejects(
|
||||
PreferenceExperiments.start({
|
||||
name: "test",
|
||||
branch: "branch",
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "value",
|
||||
preferenceType: "string",
|
||||
preferenceBranchType: "invalid",
|
||||
}),
|
||||
"start threw an error due to an invalid preference branch type",
|
||||
);
|
||||
}));
|
||||
|
||||
// start should save experiment data, modify the preference, and register a
|
||||
// watcher.
|
||||
add_task(withMockExperiments(withMockPreferences(async function(experiments, mockPreferences) {
|
||||
const startObserver = sinon.stub(PreferenceExperiments, "startObserver");
|
||||
mockPreferences.set("fake.preference", "oldvalue", "default");
|
||||
mockPreferences.set("fake.preference", "uservalue", "user");
|
||||
|
||||
await PreferenceExperiments.start({
|
||||
name: "test",
|
||||
branch: "branch",
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "newvalue",
|
||||
preferenceBranchType: "default",
|
||||
preferenceType: "string",
|
||||
});
|
||||
ok("test" in experiments, "start saved the experiment");
|
||||
ok(
|
||||
startObserver.calledWith("test", "fake.preference", "newvalue"),
|
||||
"start registered an observer",
|
||||
);
|
||||
|
||||
const expectedExperiment = {
|
||||
name: "test",
|
||||
branch: "branch",
|
||||
expired: false,
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "newvalue",
|
||||
preferenceType: "string",
|
||||
previousPreferenceValue: "oldvalue",
|
||||
preferenceBranchType: "default",
|
||||
};
|
||||
const experiment = {};
|
||||
Object.keys(expectedExperiment).forEach(key => experiment[key] = experiments.test[key]);
|
||||
Assert.deepEqual(experiment, expectedExperiment, "start saved the experiment");
|
||||
|
||||
is(
|
||||
DefaultPreferences.get("fake.preference"),
|
||||
"newvalue",
|
||||
"start modified the default preference",
|
||||
);
|
||||
is(
|
||||
Preferences.get("fake.preference"),
|
||||
"uservalue",
|
||||
"start did not modify the user preference",
|
||||
);
|
||||
|
||||
startObserver.restore();
|
||||
})));
|
||||
|
||||
// start should modify the user preference for the user branch type
|
||||
add_task(withMockExperiments(withMockPreferences(async function(experiments, mockPreferences) {
|
||||
const startObserver = sinon.stub(PreferenceExperiments, "startObserver");
|
||||
mockPreferences.set("fake.preference", "oldvalue", "user");
|
||||
mockPreferences.set("fake.preference", "olddefaultvalue", "default");
|
||||
|
||||
await PreferenceExperiments.start({
|
||||
name: "test",
|
||||
branch: "branch",
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "newvalue",
|
||||
preferenceType: "string",
|
||||
preferenceBranchType: "user",
|
||||
});
|
||||
ok(
|
||||
startObserver.calledWith("test", "fake.preference", "newvalue"),
|
||||
"start registered an observer",
|
||||
);
|
||||
|
||||
const expectedExperiment = {
|
||||
name: "test",
|
||||
branch: "branch",
|
||||
expired: false,
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "newvalue",
|
||||
preferenceType: "string",
|
||||
previousPreferenceValue: "oldvalue",
|
||||
preferenceBranchType: "user",
|
||||
};
|
||||
|
||||
const experiment = {};
|
||||
Object.keys(expectedExperiment).forEach(key => experiment[key] = experiments.test[key]);
|
||||
Assert.deepEqual(experiment, expectedExperiment, "start saved the experiment");
|
||||
|
||||
Assert.notEqual(
|
||||
DefaultPreferences.get("fake.preference"),
|
||||
"newvalue",
|
||||
"start did not modify the default preference",
|
||||
);
|
||||
is(Preferences.get("fake.preference"), "newvalue", "start modified the user preference");
|
||||
|
||||
startObserver.restore();
|
||||
})));
|
||||
|
||||
// start should detect if a new preference value type matches the previous value type
|
||||
add_task(withMockPreferences(async function(mockPreferences) {
|
||||
mockPreferences.set("fake.type_preference", "oldvalue");
|
||||
|
||||
await Assert.rejects(
|
||||
PreferenceExperiments.start({
|
||||
name: "test",
|
||||
branch: "branch",
|
||||
preferenceName: "fake.type_preference",
|
||||
preferenceBranchType: "user",
|
||||
preferenceValue: 12345,
|
||||
preferenceType: "integer",
|
||||
}),
|
||||
"start threw error for incompatible preference type"
|
||||
);
|
||||
}));
|
||||
|
||||
|
||||
// startObserver should throw if an observer for the experiment is already
|
||||
// active.
|
||||
add_task(withMockExperiments(async function() {
|
||||
PreferenceExperiments.startObserver("test", "fake.preference", "newvalue");
|
||||
Assert.throws(
|
||||
() => PreferenceExperiments.startObserver("test", "another.fake", "othervalue"),
|
||||
"startObserver threw due to a conflicting active observer",
|
||||
);
|
||||
PreferenceExperiments.stopAllObservers();
|
||||
}));
|
||||
|
||||
// startObserver should register an observer that calls stop when a preference
|
||||
// changes from its experimental value.
|
||||
add_task(withMockExperiments(withMockPreferences(async function(mockExperiments, mockPreferences) {
|
||||
const stop = sinon.stub(PreferenceExperiments, "stop");
|
||||
mockPreferences.set("fake.preference", "startvalue");
|
||||
|
||||
// NOTE: startObserver does not modify the pref
|
||||
PreferenceExperiments.startObserver("test", "fake.preference", "experimentvalue");
|
||||
|
||||
// Setting it to the experimental value should not trigger the call.
|
||||
Preferences.set("fake.preference", "experimentvalue");
|
||||
ok(!stop.called, "Changing to the experimental pref value did not trigger the observer");
|
||||
|
||||
// Setting it to something different should trigger the call.
|
||||
Preferences.set("fake.preference", "newvalue");
|
||||
ok(stop.called, "Changing to a different value triggered the observer");
|
||||
|
||||
PreferenceExperiments.stopAllObservers();
|
||||
stop.restore();
|
||||
})));
|
||||
|
||||
add_task(withMockExperiments(async function testHasObserver() {
|
||||
PreferenceExperiments.startObserver("test", "fake.preference", "experimentValue");
|
||||
|
||||
ok(await PreferenceExperiments.hasObserver("test"), "hasObserver detects active observers");
|
||||
ok(
|
||||
!(await PreferenceExperiments.hasObserver("missing")),
|
||||
"hasObserver doesn't detect inactive observers",
|
||||
);
|
||||
|
||||
PreferenceExperiments.stopAllObservers();
|
||||
}));
|
||||
|
||||
// stopObserver should throw if there is no observer active for it to stop.
|
||||
add_task(withMockExperiments(async function() {
|
||||
Assert.throws(
|
||||
() => PreferenceExperiments.stopObserver("neveractive", "another.fake", "othervalue"),
|
||||
"stopObserver threw because there was not matching active observer",
|
||||
);
|
||||
}));
|
||||
|
||||
// stopObserver should cancel an active observer.
|
||||
add_task(withMockExperiments(withMockPreferences(async function(mockExperiments, mockPreferences) {
|
||||
const stop = sinon.stub(PreferenceExperiments, "stop");
|
||||
mockPreferences.set("fake.preference", "startvalue");
|
||||
|
||||
PreferenceExperiments.startObserver("test", "fake.preference", "experimentvalue");
|
||||
PreferenceExperiments.stopObserver("test");
|
||||
|
||||
// Setting the preference now that the observer is stopped should not call
|
||||
// stop.
|
||||
Preferences.set("fake.preference", "newvalue");
|
||||
ok(!stop.called, "stopObserver successfully removed the observer");
|
||||
|
||||
// Now that the observer is stopped, start should be able to start a new one
|
||||
// without throwing.
|
||||
try {
|
||||
PreferenceExperiments.startObserver("test", "fake.preference", "experimentvalue");
|
||||
} catch (err) {
|
||||
ok(false, "startObserver did not throw an error for an observer that was already stopped");
|
||||
}
|
||||
|
||||
PreferenceExperiments.stopAllObservers();
|
||||
stop.restore();
|
||||
})));
|
||||
|
||||
// stopAllObservers
|
||||
add_task(withMockExperiments(withMockPreferences(async function(mockExperiments, mockPreferences) {
|
||||
const stop = sinon.stub(PreferenceExperiments, "stop");
|
||||
mockPreferences.set("fake.preference", "startvalue");
|
||||
mockPreferences.set("other.fake.preference", "startvalue");
|
||||
|
||||
PreferenceExperiments.startObserver("test", "fake.preference", "experimentvalue");
|
||||
PreferenceExperiments.startObserver("test2", "other.fake.preference", "experimentvalue");
|
||||
PreferenceExperiments.stopAllObservers();
|
||||
|
||||
// Setting the preference now that the observers are stopped should not call
|
||||
// stop.
|
||||
Preferences.set("fake.preference", "newvalue");
|
||||
Preferences.set("other.fake.preference", "newvalue");
|
||||
ok(!stop.called, "stopAllObservers successfully removed all observers");
|
||||
|
||||
// Now that the observers are stopped, start should be able to start new
|
||||
// observers without throwing.
|
||||
try {
|
||||
PreferenceExperiments.startObserver("test", "fake.preference", "experimentvalue");
|
||||
PreferenceExperiments.startObserver("test2", "other.fake.preference", "experimentvalue");
|
||||
} catch (err) {
|
||||
ok(false, "startObserver did not throw an error for an observer that was already stopped");
|
||||
}
|
||||
|
||||
PreferenceExperiments.stopAllObservers();
|
||||
stop.restore();
|
||||
})));
|
||||
|
||||
// markLastSeen should throw if it can't find a matching experiment
|
||||
add_task(withMockExperiments(async function() {
|
||||
await Assert.rejects(
|
||||
PreferenceExperiments.markLastSeen("neveractive"),
|
||||
"markLastSeen threw because there was not a matching experiment",
|
||||
);
|
||||
}));
|
||||
|
||||
// markLastSeen should update the lastSeen date
|
||||
add_task(withMockExperiments(async function(experiments) {
|
||||
const oldDate = new Date(1988, 10, 1).toJSON();
|
||||
experiments["test"] = experimentFactory({name: "test", lastSeen: oldDate});
|
||||
await PreferenceExperiments.markLastSeen("test");
|
||||
Assert.notEqual(
|
||||
experiments["test"].lastSeen,
|
||||
oldDate,
|
||||
"markLastSeen updated the experiment lastSeen date",
|
||||
);
|
||||
}));
|
||||
|
||||
// stop should throw if an experiment with the given name doesn't exist
|
||||
add_task(withMockExperiments(async function() {
|
||||
await Assert.rejects(
|
||||
PreferenceExperiments.stop("test"),
|
||||
"stop threw an error because there are no experiments with the given name",
|
||||
);
|
||||
}));
|
||||
|
||||
// stop should throw if the experiment is already expired
|
||||
add_task(withMockExperiments(async function(experiments) {
|
||||
experiments["test"] = experimentFactory({name: "test", expired: true});
|
||||
await Assert.rejects(
|
||||
PreferenceExperiments.stop("test"),
|
||||
"stop threw an error because the experiment was already expired",
|
||||
);
|
||||
}));
|
||||
|
||||
// stop should mark the experiment as expired, stop its observer, and revert the
|
||||
// preference value.
|
||||
add_task(withMockExperiments(withMockPreferences(async function(experiments, mockPreferences) {
|
||||
const stopObserver = sinon.spy(PreferenceExperiments, "stopObserver");
|
||||
|
||||
mockPreferences.set("fake.preference", "experimentvalue", "default");
|
||||
experiments["test"] = experimentFactory({
|
||||
name: "test",
|
||||
expired: false,
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "experimentvalue",
|
||||
preferenceType: "string",
|
||||
previousPreferenceValue: "oldvalue",
|
||||
preferenceBranchType: "default",
|
||||
});
|
||||
PreferenceExperiments.startObserver("test", "fake.preference", "experimentvalue");
|
||||
|
||||
await PreferenceExperiments.stop("test");
|
||||
ok(stopObserver.calledWith("test"), "stop removed an observer");
|
||||
is(experiments["test"].expired, true, "stop marked the experiment as expired");
|
||||
is(
|
||||
DefaultPreferences.get("fake.preference"),
|
||||
"oldvalue",
|
||||
"stop reverted the preference to its previous value",
|
||||
);
|
||||
|
||||
stopObserver.restore();
|
||||
PreferenceExperiments.stopAllObservers();
|
||||
})));
|
||||
|
||||
// stop should also support user pref experiments
|
||||
add_task(withMockExperiments(withMockPreferences(async function(experiments, mockPreferences) {
|
||||
const stopObserver = sinon.stub(PreferenceExperiments, "stopObserver");
|
||||
mockPreferences.set("fake.preference", "experimentvalue", "user");
|
||||
experiments["test"] = experimentFactory({
|
||||
name: "test",
|
||||
expired: false,
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "experimentvalue",
|
||||
preferenceType: "string",
|
||||
previousPreferenceValue: "oldvalue",
|
||||
preferenceBranchType: "user",
|
||||
});
|
||||
PreferenceExperiments.startObserver("test", "fake.preference", "experimentvalue");
|
||||
|
||||
await PreferenceExperiments.stop("test");
|
||||
ok(stopObserver.calledWith("test"), "stop removed an observer");
|
||||
is(experiments["test"].expired, true, "stop marked the experiment as expired");
|
||||
is(
|
||||
Preferences.get("fake.preference"),
|
||||
"oldvalue",
|
||||
"stop reverted the preference to its previous value",
|
||||
);
|
||||
|
||||
stopObserver.restore();
|
||||
})));
|
||||
|
||||
// stop should not call stopObserver if there is no observer registered.
|
||||
add_task(withMockExperiments(withMockPreferences(async function(experiments) {
|
||||
const stopObserver = sinon.spy(PreferenceExperiments, "stopObserver");
|
||||
experiments["test"] = experimentFactory({name: "test", expired: false});
|
||||
|
||||
await PreferenceExperiments.stop("test");
|
||||
ok(!stopObserver.called, "stop did not bother to stop an observer that wasn't active");
|
||||
|
||||
stopObserver.restore();
|
||||
PreferenceExperiments.stopAllObservers();
|
||||
})));
|
||||
|
||||
// stop should remove a preference that had no value prior to an experiment for user prefs
|
||||
add_task(withMockExperiments(withMockPreferences(async function(experiments, mockPreferences) {
|
||||
const stopObserver = sinon.stub(PreferenceExperiments, "stopObserver");
|
||||
mockPreferences.set("fake.preference", "experimentvalue", "user");
|
||||
experiments["test"] = experimentFactory({
|
||||
name: "test",
|
||||
expired: false,
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "experimentvalue",
|
||||
preferenceType: "string",
|
||||
previousPreferenceValue: undefined,
|
||||
preferenceBranchType: "user",
|
||||
});
|
||||
|
||||
await PreferenceExperiments.stop("test");
|
||||
ok(
|
||||
!Preferences.isSet("fake.preference"),
|
||||
"stop removed the preference that had no value prior to the experiment",
|
||||
);
|
||||
|
||||
stopObserver.restore();
|
||||
})));
|
||||
|
||||
// stop should not modify a preference if resetValue is false
|
||||
add_task(withMockExperiments(withMockPreferences(async function(experiments, mockPreferences) {
|
||||
const stopObserver = sinon.stub(PreferenceExperiments, "stopObserver");
|
||||
mockPreferences.set("fake.preference", "customvalue", "default");
|
||||
experiments["test"] = experimentFactory({
|
||||
name: "test",
|
||||
expired: false,
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "experimentvalue",
|
||||
preferenceType: "string",
|
||||
previousPreferenceValue: "oldvalue",
|
||||
peferenceBranchType: "default",
|
||||
});
|
||||
|
||||
await PreferenceExperiments.stop("test", false);
|
||||
is(
|
||||
DefaultPreferences.get("fake.preference"),
|
||||
"customvalue",
|
||||
"stop did not modify the preference",
|
||||
);
|
||||
|
||||
stopObserver.restore();
|
||||
})));
|
||||
|
||||
// get should throw if no experiment exists with the given name
|
||||
add_task(withMockExperiments(async function() {
|
||||
await Assert.rejects(
|
||||
PreferenceExperiments.get("neverexisted"),
|
||||
"get rejects if no experiment with the given name is found",
|
||||
);
|
||||
}));
|
||||
|
||||
// get
|
||||
add_task(withMockExperiments(async function(experiments) {
|
||||
const experiment = experimentFactory({name: "test"});
|
||||
experiments["test"] = experiment;
|
||||
|
||||
const fetchedExperiment = await PreferenceExperiments.get("test");
|
||||
Assert.deepEqual(fetchedExperiment, experiment, "get fetches the correct experiment");
|
||||
|
||||
// Modifying the fetched experiment must not edit the data source.
|
||||
fetchedExperiment.name = "othername";
|
||||
is(experiments["test"].name, "test", "get returns a copy of the experiment");
|
||||
}));
|
||||
|
||||
add_task(withMockExperiments(async function testGetAll(experiments) {
|
||||
const experiment1 = experimentFactory({name: "experiment1"});
|
||||
const experiment2 = experimentFactory({name: "experiment2", disabled: true});
|
||||
experiments["experiment1"] = experiment1;
|
||||
experiments["experiment2"] = experiment2;
|
||||
|
||||
const fetchedExperiments = await PreferenceExperiments.getAll();
|
||||
is(fetchedExperiments.length, 2, "getAll returns a list of all stored experiments");
|
||||
Assert.deepEqual(
|
||||
fetchedExperiments.find(e => e.name === "experiment1"),
|
||||
experiment1,
|
||||
"getAll returns a list with the correct experiments",
|
||||
);
|
||||
const fetchedExperiment2 = fetchedExperiments.find(e => e.name === "experiment2");
|
||||
Assert.deepEqual(
|
||||
fetchedExperiment2,
|
||||
experiment2,
|
||||
"getAll returns a list with the correct experiments, including disabled ones",
|
||||
);
|
||||
|
||||
fetchedExperiment2.name = "othername";
|
||||
is(experiment2.name, "experiment2", "getAll returns copies of the experiments");
|
||||
}));
|
||||
|
||||
add_task(withMockExperiments(withMockPreferences(async function testGetAllActive(experiments) {
|
||||
experiments["active"] = experimentFactory({
|
||||
name: "active",
|
||||
expired: false,
|
||||
});
|
||||
experiments["inactive"] = experimentFactory({
|
||||
name: "inactive",
|
||||
expired: true,
|
||||
});
|
||||
|
||||
const activeExperiments = await PreferenceExperiments.getAllActive();
|
||||
Assert.deepEqual(
|
||||
activeExperiments,
|
||||
[experiments["active"]],
|
||||
"getAllActive only returns active experiments",
|
||||
);
|
||||
|
||||
activeExperiments[0].name = "newfakename";
|
||||
Assert.notEqual(
|
||||
experiments["active"].name,
|
||||
"newfakename",
|
||||
"getAllActive returns copies of stored experiments",
|
||||
);
|
||||
})));
|
||||
|
||||
// has
|
||||
add_task(withMockExperiments(async function(experiments) {
|
||||
experiments["test"] = experimentFactory({name: "test"});
|
||||
ok(await PreferenceExperiments.has("test"), "has returned true for a stored experiment");
|
||||
ok(!(await PreferenceExperiments.has("missing")), "has returned false for a missing experiment");
|
||||
}));
|
||||
|
||||
// init should set the default preference value for active, default experiments
|
||||
add_task(withMockExperiments(withMockPreferences(async function testInit(experiments, mockPreferences) {
|
||||
experiments["user"] = experimentFactory({
|
||||
name: "user",
|
||||
preferenceName: "user",
|
||||
preferenceValue: true,
|
||||
preferenceType: "boolean",
|
||||
expired: false,
|
||||
preferenceBranchType: "user",
|
||||
});
|
||||
experiments["default"] = experimentFactory({
|
||||
name: "default",
|
||||
preferenceName: "default",
|
||||
preferenceValue: true,
|
||||
preferenceType: "boolean",
|
||||
expired: false,
|
||||
preferenceBranchType: "default",
|
||||
});
|
||||
experiments["expireddefault"] = experimentFactory({
|
||||
name: "expireddefault",
|
||||
preferenceName: "expireddefault",
|
||||
preferenceValue: true,
|
||||
preferenceType: "boolean",
|
||||
expired: true,
|
||||
preferenceBranchType: "default",
|
||||
});
|
||||
|
||||
for (const experiment of Object.values(experiments)) {
|
||||
mockPreferences.set(experiment.preferenceName, false, "default");
|
||||
}
|
||||
|
||||
await PreferenceExperiments.init();
|
||||
|
||||
is(DefaultPreferences.get("user"), false, "init ignored a user pref experiment");
|
||||
is(
|
||||
DefaultPreferences.get("default"),
|
||||
true,
|
||||
"init set the value for a default pref experiment",
|
||||
);
|
||||
is(
|
||||
DefaultPreferences.get("expireddefault"),
|
||||
false,
|
||||
"init ignored an expired default pref experiment",
|
||||
);
|
||||
})));
|
||||
|
||||
// init should register telemetry experiments
|
||||
add_task(withMockExperiments(withMockPreferences(async function testInit(experiments, mockPreferences) {
|
||||
const setActiveStub = sinon.stub(TelemetryEnvironment, "setExperimentActive");
|
||||
const startObserverStub = sinon.stub(PreferenceExperiments, "startObserver");
|
||||
mockPreferences.set("fake.pref", "experiment value");
|
||||
|
||||
experiments["test"] = experimentFactory({
|
||||
name: "test",
|
||||
branch: "branch",
|
||||
preferenceName: "fake.pref",
|
||||
preferenceValue: "experiment value",
|
||||
expired: false,
|
||||
preferenceBranchType: "default",
|
||||
});
|
||||
|
||||
await PreferenceExperiments.init();
|
||||
ok(setActiveStub.calledWith("test", "branch"), "Experiment is registered by init");
|
||||
startObserverStub.restore();
|
||||
setActiveStub.restore();
|
||||
})));
|
||||
|
||||
// starting and stopping experiments should register in telemetry
|
||||
add_task(withMockExperiments(async function testInitTelemetry() {
|
||||
const setActiveStub = sinon.stub(TelemetryEnvironment, "setExperimentActive");
|
||||
const setInactiveStub = sinon.stub(TelemetryEnvironment, "setExperimentInactive");
|
||||
|
||||
await PreferenceExperiments.start({
|
||||
name: "test",
|
||||
branch: "branch",
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "value",
|
||||
preferenceType: "string",
|
||||
preferenceBranchType: "default",
|
||||
});
|
||||
|
||||
ok(setActiveStub.calledWith("test", "branch"), "Experiment is registerd by start()");
|
||||
await PreferenceExperiments.stop("test");
|
||||
ok(setInactiveStub.calledWith("test", "branch"), "Experiment is unregisterd by stop()");
|
||||
|
||||
setActiveStub.restore();
|
||||
setInactiveStub.restore();
|
||||
}));
|
||||
|
||||
// Experiments shouldn't be recorded by init() in telemetry if they are expired
|
||||
add_task(withMockExperiments(async function testInitTelemetryExpired(experiments) {
|
||||
const setActiveStub = sinon.stub(TelemetryEnvironment, "setExperimentActive");
|
||||
experiments["experiment1"] = experimentFactory({name: "expired", branch: "branch", expired: true});
|
||||
await PreferenceExperiments.init();
|
||||
ok(!setActiveStub.called, "Expired experiment is not registered by init");
|
||||
setActiveStub.restore();
|
||||
}));
|
||||
|
||||
// Experiments should end if the preference has been changed when init() is called
|
||||
add_task(withMockExperiments(withMockPreferences(async function testInitChanges(experiments, mockPreferences) {
|
||||
const stopStub = sinon.stub(PreferenceExperiments, "stop");
|
||||
mockPreferences.set("fake.preference", "experiment value", "default");
|
||||
experiments["test"] = experimentFactory({
|
||||
name: "test",
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "experiment value",
|
||||
});
|
||||
mockPreferences.set("fake.preference", "changed value");
|
||||
await PreferenceExperiments.init();
|
||||
ok(stopStub.calledWith("test"), "Experiment is stopped because value changed");
|
||||
ok(Preferences.get("fake.preference"), "changed value", "Preference value was not changed");
|
||||
stopStub.restore();
|
||||
})));
|
||||
|
||||
|
||||
// init should register an observer for experiments
|
||||
add_task(withMockExperiments(withMockPreferences(async function testInitRegistersObserver(experiments, mockPreferences) {
|
||||
const startObserver = sinon.stub(PreferenceExperiments, "startObserver");
|
||||
mockPreferences.set("fake.preference", "experiment value", "default");
|
||||
experiments["test"] = experimentFactory({
|
||||
name: "test",
|
||||
preferenceName: "fake.preference",
|
||||
preferenceValue: "experiment value",
|
||||
});
|
||||
await PreferenceExperiments.init();
|
||||
|
||||
ok(
|
||||
startObserver.calledWith("test", "fake.preference", "experiment value"),
|
||||
"init registered an observer",
|
||||
);
|
||||
|
||||
startObserver.restore();
|
||||
})));
|
|
@ -1,93 +1,15 @@
|
|||
"use strict";
|
||||
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/RecipeRunner.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/ClientEnvironment.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/CleanupManager.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/ActionSandboxManager.jsm", this);
|
||||
|
||||
add_task(function* execute() {
|
||||
// Test that RecipeRunner can execute a basic recipe/action and return
|
||||
// the result of execute.
|
||||
const recipe = {
|
||||
foo: "bar",
|
||||
};
|
||||
const actionScript = `
|
||||
class TestAction {
|
||||
constructor(driver, recipe) {
|
||||
this.recipe = recipe;
|
||||
}
|
||||
|
||||
execute() {
|
||||
return new Promise(resolve => {
|
||||
resolve({foo: this.recipe.foo});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerAction('test-action', TestAction);
|
||||
`;
|
||||
|
||||
const result = yield RecipeRunner.executeAction(recipe, actionScript);
|
||||
is(result.foo, "bar", "Recipe executed correctly");
|
||||
});
|
||||
|
||||
add_task(function* error() {
|
||||
// Test that RecipeRunner rejects with error messages from within the
|
||||
// sandbox.
|
||||
const actionScript = `
|
||||
class TestAction {
|
||||
execute() {
|
||||
return new Promise((resolve, reject) => {
|
||||
reject(new Error("ERROR MESSAGE"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerAction('test-action', TestAction);
|
||||
`;
|
||||
|
||||
let gotException = false;
|
||||
try {
|
||||
yield RecipeRunner.executeAction({}, actionScript);
|
||||
} catch (err) {
|
||||
gotException = true;
|
||||
is(err.message, "ERROR MESSAGE", "RecipeRunner throws errors from the sandbox correctly.");
|
||||
}
|
||||
ok(gotException, "RecipeRunner threw an error from the sandbox.");
|
||||
});
|
||||
|
||||
add_task(function* globalObject() {
|
||||
// Test that window is an alias for the global object, and that it
|
||||
// has some expected functions available on it.
|
||||
const actionScript = `
|
||||
window.setOnWindow = "set";
|
||||
this.setOnGlobal = "set";
|
||||
|
||||
class TestAction {
|
||||
execute() {
|
||||
return new Promise(resolve => {
|
||||
resolve({
|
||||
setOnWindow: setOnWindow,
|
||||
setOnGlobal: window.setOnGlobal,
|
||||
setTimeoutExists: setTimeout !== undefined,
|
||||
clearTimeoutExists: clearTimeout !== undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerAction('test-action', TestAction);
|
||||
`;
|
||||
|
||||
const result = yield RecipeRunner.executeAction({}, actionScript);
|
||||
Assert.deepEqual(result, {
|
||||
setOnWindow: "set",
|
||||
setOnGlobal: "set",
|
||||
setTimeoutExists: true,
|
||||
clearTimeoutExists: true,
|
||||
}, "sandbox.window is the global object and has expected functions.");
|
||||
});
|
||||
|
||||
add_task(function* getFilterContext() {
|
||||
const context = RecipeRunner.getFilterContext();
|
||||
add_task(async function getFilterContext() {
|
||||
const recipe = {id: 17, arguments: {foo: "bar"}, unrelated: false};
|
||||
const context = RecipeRunner.getFilterContext(recipe);
|
||||
|
||||
// Test for expected properties in the filter expression context.
|
||||
const expectedNormandyKeys = [
|
||||
|
@ -98,6 +20,7 @@ add_task(function* getFilterContext() {
|
|||
"isDefaultBrowser",
|
||||
"locale",
|
||||
"plugins",
|
||||
"recipe",
|
||||
"request_time",
|
||||
"searchEngine",
|
||||
"syncDesktopDevices",
|
||||
|
@ -111,38 +34,190 @@ add_task(function* getFilterContext() {
|
|||
for (const key of expectedNormandyKeys) {
|
||||
ok(key in context.normandy, `normandy.${key} is available`);
|
||||
}
|
||||
|
||||
is(
|
||||
context.normandy.recipe.id,
|
||||
recipe.id,
|
||||
"normandy.recipe is the recipe passed to getFilterContext",
|
||||
);
|
||||
delete recipe.unrelated;
|
||||
Assert.deepEqual(
|
||||
context.normandy.recipe,
|
||||
recipe,
|
||||
"normandy.recipe drops unrecognized attributes from the recipe",
|
||||
);
|
||||
});
|
||||
|
||||
add_task(function* checkFilter() {
|
||||
add_task(async function checkFilter() {
|
||||
const check = filter => RecipeRunner.checkFilter({filter_expression: filter});
|
||||
|
||||
// Errors must result in a false return value.
|
||||
ok(!(yield check("invalid ( + 5yntax")), "Invalid filter expressions return false");
|
||||
ok(!(await check("invalid ( + 5yntax")), "Invalid filter expressions return false");
|
||||
|
||||
// Non-boolean filter results result in a true return value.
|
||||
ok(yield check("[1, 2, 3]"), "Non-boolean filter expressions return true");
|
||||
ok(await check("[1, 2, 3]"), "Non-boolean filter expressions return true");
|
||||
|
||||
// The given recipe must be available to the filter context.
|
||||
const recipe = {filter_expression: "normandy.recipe.id == 7", id: 7};
|
||||
ok(await RecipeRunner.checkFilter(recipe), "The recipe is available in the filter context");
|
||||
recipe.id = 4;
|
||||
ok(!(await RecipeRunner.checkFilter(recipe)), "The recipe is available in the filter context");
|
||||
});
|
||||
|
||||
add_task(function* testStart() {
|
||||
add_task(withMockNormandyApi(async function testClientClassificationCache() {
|
||||
const getStub = sinon.stub(ClientEnvironment, "getClientClassification")
|
||||
.returns(Promise.resolve(false));
|
||||
|
||||
await SpecialPowers.pushPrefEnv({set: [
|
||||
["extensions.shield-recipe-client.api_url",
|
||||
"https://example.com/selfsupport-dummy"],
|
||||
]});
|
||||
|
||||
// When the experiment pref is false, eagerly call getClientClassification.
|
||||
yield SpecialPowers.pushPrefEnv({set: [
|
||||
await SpecialPowers.pushPrefEnv({set: [
|
||||
["extensions.shield-recipe-client.experiments.lazy_classify", false],
|
||||
]});
|
||||
ok(!getStub.called, "getClientClassification hasn't been called");
|
||||
yield RecipeRunner.start();
|
||||
ok(getStub.called, "getClientClassfication was called eagerly");
|
||||
await RecipeRunner.run();
|
||||
ok(getStub.called, "getClientClassification was called eagerly");
|
||||
|
||||
// When the experiment pref is true, do not eagerly call getClientClassification.
|
||||
yield SpecialPowers.pushPrefEnv({set: [
|
||||
await SpecialPowers.pushPrefEnv({set: [
|
||||
["extensions.shield-recipe-client.experiments.lazy_classify", true],
|
||||
]});
|
||||
getStub.reset();
|
||||
ok(!getStub.called, "getClientClassification hasn't been called");
|
||||
yield RecipeRunner.start();
|
||||
ok(!getStub.called, "getClientClassfication was not called eagerly");
|
||||
await RecipeRunner.run();
|
||||
ok(!getStub.called, "getClientClassification was not called eagerly");
|
||||
|
||||
getStub.restore();
|
||||
}));
|
||||
|
||||
/**
|
||||
* Mocks RecipeRunner.loadActionSandboxManagers for testing run.
|
||||
*/
|
||||
async function withMockActionSandboxManagers(actions, testFunction) {
|
||||
const managers = {};
|
||||
for (const action of actions) {
|
||||
managers[action.name] = new ActionSandboxManager("");
|
||||
sinon.stub(managers[action.name], "runAsyncCallback");
|
||||
}
|
||||
|
||||
const loadActionSandboxManagers = sinon.stub(
|
||||
RecipeRunner,
|
||||
"loadActionSandboxManagers",
|
||||
async () => managers,
|
||||
);
|
||||
await testFunction(managers);
|
||||
loadActionSandboxManagers.restore();
|
||||
}
|
||||
|
||||
add_task(withMockNormandyApi(async function testRun(mockApi) {
|
||||
const matchAction = {name: "matchAction"};
|
||||
const noMatchAction = {name: "noMatchAction"};
|
||||
mockApi.actions = [matchAction, noMatchAction];
|
||||
|
||||
const matchRecipe = {action: "matchAction", filter_expression: "true"};
|
||||
const noMatchRecipe = {action: "noMatchAction", filter_expression: "false"};
|
||||
const missingRecipe = {action: "missingAction", filter_expression: "true"};
|
||||
mockApi.recipes = [matchRecipe, noMatchRecipe, missingRecipe];
|
||||
|
||||
await withMockActionSandboxManagers(mockApi.actions, async managers => {
|
||||
const matchManager = managers["matchAction"];
|
||||
const noMatchManager = managers["noMatchAction"];
|
||||
|
||||
await RecipeRunner.run();
|
||||
|
||||
// match should be called for preExecution, action, and postExecution
|
||||
sinon.assert.calledWith(matchManager.runAsyncCallback, "preExecution");
|
||||
sinon.assert.calledWith(matchManager.runAsyncCallback, "action", matchRecipe);
|
||||
sinon.assert.calledWith(matchManager.runAsyncCallback, "postExecution");
|
||||
|
||||
// noMatch should be called for preExecution and postExecution, and skipped
|
||||
// for action since the filter expression does not match.
|
||||
sinon.assert.calledWith(noMatchManager.runAsyncCallback, "preExecution");
|
||||
sinon.assert.neverCalledWith(noMatchManager.runAsyncCallback, "action", noMatchRecipe);
|
||||
sinon.assert.calledWith(noMatchManager.runAsyncCallback, "postExecution");
|
||||
|
||||
// missing is never called at all due to no matching action/manager.
|
||||
await matchManager.isNuked();
|
||||
await noMatchManager.isNuked();
|
||||
});
|
||||
}));
|
||||
|
||||
add_task(withMockNormandyApi(async function testRunPreExecutionFailure(mockApi) {
|
||||
const passAction = {name: "passAction"};
|
||||
const failAction = {name: "failAction"};
|
||||
mockApi.actions = [passAction, failAction];
|
||||
|
||||
const passRecipe = {action: "passAction", filter_expression: "true"};
|
||||
const failRecipe = {action: "failAction", filter_expression: "true"};
|
||||
mockApi.recipes = [passRecipe, failRecipe];
|
||||
|
||||
await withMockActionSandboxManagers(mockApi.actions, async managers => {
|
||||
const passManager = managers["passAction"];
|
||||
const failManager = managers["failAction"];
|
||||
failManager.runAsyncCallback.returns(Promise.reject(new Error("oh no")));
|
||||
|
||||
await RecipeRunner.run();
|
||||
|
||||
// pass should be called for preExecution, action, and postExecution
|
||||
sinon.assert.calledWith(passManager.runAsyncCallback, "preExecution");
|
||||
sinon.assert.calledWith(passManager.runAsyncCallback, "action", passRecipe);
|
||||
sinon.assert.calledWith(passManager.runAsyncCallback, "postExecution");
|
||||
|
||||
// fail should only be called for preExecution, since it fails during that
|
||||
sinon.assert.calledWith(failManager.runAsyncCallback, "preExecution");
|
||||
sinon.assert.neverCalledWith(failManager.runAsyncCallback, "action", failRecipe);
|
||||
sinon.assert.neverCalledWith(failManager.runAsyncCallback, "postExecution");
|
||||
|
||||
await passManager.isNuked();
|
||||
await failManager.isNuked();
|
||||
});
|
||||
}));
|
||||
|
||||
add_task(withMockNormandyApi(async function testLoadActionSandboxManagers(mockApi) {
|
||||
mockApi.actions = [
|
||||
{name: "normalAction"},
|
||||
{name: "missingImpl"},
|
||||
];
|
||||
mockApi.implementations["normalAction"] = "window.scriptRan = true";
|
||||
|
||||
const managers = await RecipeRunner.loadActionSandboxManagers();
|
||||
ok("normalAction" in managers, "Actions with implementations have managers");
|
||||
ok(!("missingImpl" in managers), "Actions without implementations are skipped");
|
||||
|
||||
const normalManager = managers["normalAction"];
|
||||
ok(
|
||||
await normalManager.evalInSandbox("window.scriptRan"),
|
||||
"Implementations are run in the sandbox",
|
||||
);
|
||||
}));
|
||||
|
||||
add_task(async function testStartup() {
|
||||
const runStub = sinon.stub(RecipeRunner, "run");
|
||||
const addCleanupHandlerStub = sinon.stub(CleanupManager, "addCleanupHandler");
|
||||
const updateRunIntervalStub = sinon.stub(RecipeRunner, "updateRunInterval");
|
||||
|
||||
// in dev mode
|
||||
await SpecialPowers.pushPrefEnv({set: [["extensions.shield-recipe-client.dev_mode", true]]});
|
||||
RecipeRunner.init();
|
||||
ok(runStub.called, "RecipeRunner.run is called immediately when in dev mode");
|
||||
ok(addCleanupHandlerStub.called, "A cleanup function is registered when in dev mode");
|
||||
ok(updateRunIntervalStub.called, "A timer is registered when in dev mode");
|
||||
|
||||
runStub.reset();
|
||||
addCleanupHandlerStub.reset();
|
||||
updateRunIntervalStub.reset();
|
||||
|
||||
// not in dev mode
|
||||
await SpecialPowers.pushPrefEnv({set: [["extensions.shield-recipe-client.dev_mode", false]]});
|
||||
RecipeRunner.init();
|
||||
ok(!runStub.called, "RecipeRunner.run is not called immediately when not in dev mode");
|
||||
ok(addCleanupHandlerStub.called, "A cleanup function is registered when not in dev mode");
|
||||
ok(updateRunIntervalStub.called, "A timer is registered when not in dev mode");
|
||||
|
||||
runStub.restore();
|
||||
addCleanupHandlerStub.restore();
|
||||
updateRunIntervalStub.restore();
|
||||
});
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
"use strict";
|
||||
|
||||
Cu.import("resource://shield-recipe-client/lib/ShieldRecipeClient.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/RecipeRunner.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/PreferenceExperiments.jsm", this);
|
||||
|
||||
add_task(async function testStartup() {
|
||||
sinon.stub(RecipeRunner, "init");
|
||||
sinon.stub(PreferenceExperiments, "init");
|
||||
|
||||
await ShieldRecipeClient.startup();
|
||||
ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
|
||||
ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
|
||||
|
||||
PreferenceExperiments.init.restore();
|
||||
RecipeRunner.init.restore();
|
||||
});
|
||||
|
||||
add_task(async function testStartupPrefInitFail() {
|
||||
sinon.stub(RecipeRunner, "init");
|
||||
sinon.stub(PreferenceExperiments, "init").returns(Promise.reject(new Error("oh no")));
|
||||
|
||||
await ShieldRecipeClient.startup();
|
||||
ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
|
||||
// Even if PreferenceExperiments.init fails, RecipeRunner.init should be called.
|
||||
ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
|
||||
|
||||
PreferenceExperiments.init.restore();
|
||||
RecipeRunner.init.restore();
|
||||
});
|
|
@ -1,46 +1,69 @@
|
|||
"use strict";
|
||||
|
||||
Cu.import("resource://shield-recipe-client/lib/Storage.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
|
||||
|
||||
const fakeSandbox = {Promise};
|
||||
const store1 = Storage.makeStorage("prefix1", fakeSandbox);
|
||||
const store2 = Storage.makeStorage("prefix2", fakeSandbox);
|
||||
add_task(async function() {
|
||||
const fakeSandbox = {Promise};
|
||||
const store1 = Storage.makeStorage("prefix1", fakeSandbox);
|
||||
const store2 = Storage.makeStorage("prefix2", fakeSandbox);
|
||||
|
||||
add_task(function* () {
|
||||
// Make sure values return null before being set
|
||||
Assert.equal(yield store1.getItem("key"), null);
|
||||
Assert.equal(yield store2.getItem("key"), null);
|
||||
Assert.equal(await store1.getItem("key"), null);
|
||||
Assert.equal(await store2.getItem("key"), null);
|
||||
|
||||
// Set values to check
|
||||
yield store1.setItem("key", "value1");
|
||||
yield store2.setItem("key", "value2");
|
||||
await store1.setItem("key", "value1");
|
||||
await store2.setItem("key", "value2");
|
||||
|
||||
// Check that they are available
|
||||
Assert.equal(yield store1.getItem("key"), "value1");
|
||||
Assert.equal(yield store2.getItem("key"), "value2");
|
||||
Assert.equal(await store1.getItem("key"), "value1");
|
||||
Assert.equal(await store2.getItem("key"), "value2");
|
||||
|
||||
// Remove them, and check they are gone
|
||||
yield store1.removeItem("key");
|
||||
yield store2.removeItem("key");
|
||||
Assert.equal(yield store1.getItem("key"), null);
|
||||
Assert.equal(yield store2.getItem("key"), null);
|
||||
await store1.removeItem("key");
|
||||
await store2.removeItem("key");
|
||||
Assert.equal(await store1.getItem("key"), null);
|
||||
Assert.equal(await store2.getItem("key"), null);
|
||||
|
||||
// Check that numbers are stored as numbers (not strings)
|
||||
yield store1.setItem("number", 42);
|
||||
Assert.equal(yield store1.getItem("number"), 42);
|
||||
await store1.setItem("number", 42);
|
||||
Assert.equal(await store1.getItem("number"), 42);
|
||||
|
||||
// Check complex types work
|
||||
const complex = {a: 1, b: [2, 3], c: {d: 4}};
|
||||
yield store1.setItem("complex", complex);
|
||||
Assert.deepEqual(yield store1.getItem("complex"), complex);
|
||||
await store1.setItem("complex", complex);
|
||||
Assert.deepEqual(await store1.getItem("complex"), complex);
|
||||
|
||||
// Check that clearing the storage removes data from multiple
|
||||
// prefixes.
|
||||
yield store1.setItem("removeTest", 1);
|
||||
yield store2.setItem("removeTest", 2);
|
||||
Assert.equal(yield store1.getItem("removeTest"), 1);
|
||||
Assert.equal(yield store2.getItem("removeTest"), 2);
|
||||
yield Storage.clearAllStorage();
|
||||
Assert.equal(yield store1.getItem("removeTest"), null);
|
||||
Assert.equal(yield store2.getItem("removeTest"), null);
|
||||
await store1.setItem("removeTest", 1);
|
||||
await store2.setItem("removeTest", 2);
|
||||
Assert.equal(await store1.getItem("removeTest"), 1);
|
||||
Assert.equal(await store2.getItem("removeTest"), 2);
|
||||
await Storage.clearAllStorage();
|
||||
Assert.equal(await store1.getItem("removeTest"), null);
|
||||
Assert.equal(await store2.getItem("removeTest"), null);
|
||||
});
|
||||
|
||||
add_task(async function testSandboxValueStorage() {
|
||||
const manager1 = new SandboxManager();
|
||||
const manager2 = new SandboxManager();
|
||||
const store1 = Storage.makeStorage("testSandboxValueStorage", manager1.sandbox);
|
||||
const store2 = Storage.makeStorage("testSandboxValueStorage", manager2.sandbox);
|
||||
manager1.addGlobal("store", store1);
|
||||
manager2.addGlobal("store", store2);
|
||||
manager1.addHold("testing");
|
||||
manager2.addHold("testing");
|
||||
|
||||
await manager1.evalInSandbox("store.setItem('foo', {foo: 'bar'});");
|
||||
manager1.removeHold("testing");
|
||||
await manager1.isNuked();
|
||||
|
||||
const objectMatches = await manager2.evalInSandbox(`
|
||||
store.getItem("foo").then(item => item.foo === "bar");
|
||||
`);
|
||||
ok(objectMatches, "Values persisted in a store survive after their originating sandbox is nuked");
|
||||
|
||||
manager2.removeHold("testing");
|
||||
});
|
||||
|
|
|
@ -1,14 +1,23 @@
|
|||
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
||||
|
||||
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/Utils.jsm", this);
|
||||
|
||||
// Load mocking/stubbing library, sinon
|
||||
// docs: http://sinonjs.org/docs/
|
||||
const loader = Cc["@mozilla.org/moz/jssubscript-loader;1"].getService(Ci.mozIJSSubScriptLoader);
|
||||
loader.loadSubScript("resource://testing-common/sinon-1.16.1.js");
|
||||
|
||||
registerCleanupFunction(function*() {
|
||||
// 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;
|
||||
delete window.setImmediate;
|
||||
|
@ -18,23 +27,95 @@ registerCleanupFunction(function*() {
|
|||
|
||||
this.UUID_REGEX = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/;
|
||||
|
||||
this.withSandboxManager = function(Assert, testGenerator) {
|
||||
return function* inner() {
|
||||
this.withSandboxManager = function(Assert, testFunction) {
|
||||
return async function inner() {
|
||||
const sandboxManager = new SandboxManager();
|
||||
sandboxManager.addHold("test running");
|
||||
|
||||
yield testGenerator(sandboxManager);
|
||||
await testFunction(sandboxManager);
|
||||
|
||||
sandboxManager.removeHold("test running");
|
||||
yield sandboxManager.isNuked()
|
||||
await sandboxManager.isNuked()
|
||||
.then(() => Assert.ok(true, "sandbox is nuked"))
|
||||
.catch(e => Assert.ok(false, "sandbox is nuked", e));
|
||||
};
|
||||
};
|
||||
|
||||
this.withDriver = function(Assert, testGenerator) {
|
||||
return withSandboxManager(Assert, function* inner(sandboxManager) {
|
||||
this.withDriver = function(Assert, testFunction) {
|
||||
return withSandboxManager(Assert, async function inner(sandboxManager) {
|
||||
const driver = new NormandyDriver(sandboxManager);
|
||||
yield testGenerator(driver);
|
||||
await testFunction(driver);
|
||||
});
|
||||
};
|
||||
|
||||
this.withMockNormandyApi = function(testFunction) {
|
||||
return async function inner(...args) {
|
||||
const mockApi = {actions: [], recipes: [], implementations: {}};
|
||||
|
||||
sinon.stub(NormandyApi, "fetchActions", async () => mockApi.actions);
|
||||
sinon.stub(NormandyApi, "fetchRecipes", async () => mockApi.recipes);
|
||||
sinon.stub(NormandyApi, "fetchImplementation", async action => {
|
||||
const impl = mockApi.implementations[action.name];
|
||||
if (!impl) {
|
||||
throw new Error("Missing");
|
||||
}
|
||||
return impl;
|
||||
});
|
||||
|
||||
await testFunction(mockApi, ...args);
|
||||
|
||||
NormandyApi.fetchActions.restore();
|
||||
NormandyApi.fetchRecipes.restore();
|
||||
NormandyApi.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];
|
||||
this.oldValues[branch][name] = {
|
||||
oldValue: preferenceBranch.get(name),
|
||||
existed: preferenceBranch.has(name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const [branchName, values] of Object.entries(this.oldValues)) {
|
||||
const preferenceBranch = preferenceBranches[branchName];
|
||||
for (const [name, {oldValue, existed}] of Object.entries(values)) {
|
||||
if (existed) {
|
||||
preferenceBranch.set(name, oldValue);
|
||||
} else {
|
||||
preferenceBranch.reset(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,5 +4,12 @@ module.exports = {
|
|||
globals: {
|
||||
do_get_file: false,
|
||||
equal: false,
|
||||
Cu: false,
|
||||
ok: false,
|
||||
load: false,
|
||||
do_register_cleanup: false,
|
||||
sinon: false,
|
||||
notEqual: false,
|
||||
deepEqual: false,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Reads an HTTP status code and response body from the querystring and sends
|
||||
* back a matching response.
|
||||
*/
|
||||
function handleRequest(request, response) {
|
||||
// Allow cross-origin, so you can XHR to it!
|
||||
response.setHeader("Access-Control-Allow-Origin", "*", false);
|
||||
// Avoid confusing cache behaviors
|
||||
response.setHeader("Cache-Control", "no-cache", false);
|
||||
|
||||
const params = request.queryString.split("&");
|
||||
for (const param of params) {
|
||||
const [key, value] = param.split("=");
|
||||
if (key === "status") {
|
||||
response.setStatusLine(null, value);
|
||||
} else if (key === "body") {
|
||||
response.write(value);
|
||||
}
|
||||
}
|
||||
response.write("");
|
||||
}
|
|
@ -0,0 +1,169 @@
|
|||
"use strict";
|
||||
|
||||
Cu.import("resource://shield-recipe-client/lib/ActionSandboxManager.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm");
|
||||
|
||||
async function withManager(script, testFunction) {
|
||||
const manager = new ActionSandboxManager(script);
|
||||
manager.addHold("testing");
|
||||
await testFunction(manager);
|
||||
manager.removeHold("testing");
|
||||
}
|
||||
|
||||
add_task(async function testMissingCallbackName() {
|
||||
await withManager("1 + 1", async manager => {
|
||||
equal(
|
||||
await manager.runAsyncCallback("missingCallback"),
|
||||
undefined,
|
||||
"runAsyncCallback returns undefined when given a missing callback name",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
add_task(async function testCallback() {
|
||||
const script = `
|
||||
registerAsyncCallback("testCallback", async function(normandy) {
|
||||
return 5;
|
||||
});
|
||||
`;
|
||||
|
||||
await withManager(script, async manager => {
|
||||
const result = await manager.runAsyncCallback("testCallback");
|
||||
equal(result, 5, "runAsyncCallback executes the named callback inside the sandbox");
|
||||
});
|
||||
});
|
||||
|
||||
add_task(async function testArguments() {
|
||||
const script = `
|
||||
registerAsyncCallback("testCallback", async function(normandy, a, b) {
|
||||
return a + b;
|
||||
});
|
||||
`;
|
||||
|
||||
await withManager(script, async manager => {
|
||||
const result = await manager.runAsyncCallback("testCallback", 4, 6);
|
||||
equal(result, 10, "runAsyncCallback passes arguments to the callback");
|
||||
});
|
||||
});
|
||||
|
||||
add_task(async function testCloning() {
|
||||
const script = `
|
||||
registerAsyncCallback("testCallback", async function(normandy, obj) {
|
||||
return {foo: "bar", baz: obj.baz};
|
||||
});
|
||||
`;
|
||||
|
||||
await withManager(script, async manager => {
|
||||
const result = await manager.runAsyncCallback("testCallback", {baz: "biff"});
|
||||
|
||||
deepEqual(
|
||||
result,
|
||||
{foo: "bar", baz: "biff"},
|
||||
(
|
||||
"runAsyncCallback clones arguments into the sandbox and return values into the " +
|
||||
"context it was called from"
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
add_task(async function testError() {
|
||||
const script = `
|
||||
registerAsyncCallback("testCallback", async function(normandy) {
|
||||
throw new Error("WHY")
|
||||
});
|
||||
`;
|
||||
|
||||
await withManager(script, async manager => {
|
||||
try {
|
||||
await manager.runAsyncCallback("testCallback");
|
||||
ok(false, "runAsnycCallbackFromScript throws errors when raised by the sandbox");
|
||||
} catch (err) {
|
||||
equal(err.message, "WHY", "runAsnycCallbackFromScript clones error messages");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
add_task(async function testDriver() {
|
||||
const script = `
|
||||
registerAsyncCallback("testCallback", async function(normandy) {
|
||||
return normandy;
|
||||
});
|
||||
`;
|
||||
|
||||
await withManager(script, async manager => {
|
||||
const sandboxDriver = await manager.runAsyncCallback("testCallback");
|
||||
const referenceDriver = new NormandyDriver(manager);
|
||||
equal(
|
||||
sandboxDriver.constructor.name,
|
||||
"NormandyDriver",
|
||||
"runAsyncCallback passes a driver as the first parameter",
|
||||
);
|
||||
for (const prop in referenceDriver) {
|
||||
ok(prop in sandboxDriver, "runAsyncCallback passes a driver as the first parameter");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
add_task(async function testGlobalObject() {
|
||||
// Test that window is an alias for the global object, and that it
|
||||
// has some expected functions available on it.
|
||||
const script = `
|
||||
window.setOnWindow = "set";
|
||||
this.setOnGlobal = "set";
|
||||
|
||||
registerAsyncCallback("testCallback", async function(normandy) {
|
||||
return {
|
||||
setOnWindow: setOnWindow,
|
||||
setOnGlobal: window.setOnGlobal,
|
||||
setTimeoutExists: setTimeout !== undefined,
|
||||
clearTimeoutExists: clearTimeout !== undefined,
|
||||
};
|
||||
});
|
||||
`;
|
||||
|
||||
await withManager(script, async manager => {
|
||||
const result = await manager.runAsyncCallback("testCallback");
|
||||
Assert.deepEqual(result, {
|
||||
setOnWindow: "set",
|
||||
setOnGlobal: "set",
|
||||
setTimeoutExists: true,
|
||||
clearTimeoutExists: true,
|
||||
}, "sandbox.window is the global object and has expected functions.");
|
||||
});
|
||||
});
|
||||
|
||||
add_task(async function testRegisterActionShim() {
|
||||
const recipe = {
|
||||
foo: "bar",
|
||||
};
|
||||
const script = `
|
||||
class TestAction {
|
||||
constructor(driver, recipe) {
|
||||
this.driver = driver;
|
||||
this.recipe = recipe;
|
||||
}
|
||||
|
||||
execute() {
|
||||
return new Promise(resolve => {
|
||||
resolve({
|
||||
foo: this.recipe.foo,
|
||||
isDriver: "log" in this.driver,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerAction('test-action', TestAction);
|
||||
`;
|
||||
|
||||
await withManager(script, async manager => {
|
||||
const result = await manager.runAsyncCallback("action", recipe);
|
||||
equal(result.foo, "bar", "registerAction registers an async callback for actions");
|
||||
equal(
|
||||
result.isDriver,
|
||||
true,
|
||||
"registerAction passes the driver to the action class constructor",
|
||||
);
|
||||
});
|
||||
});
|
|
@ -3,48 +3,28 @@
|
|||
/* globals Cu */
|
||||
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
Cu.import("resource://testing-common/httpd.js"); /* globals HttpServer */
|
||||
Cu.import("resource://gre/modules/osfile.jsm", this); /* globals OS */
|
||||
Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm", this);
|
||||
|
||||
class PrefManager {
|
||||
constructor() {
|
||||
this.oldValues = {};
|
||||
}
|
||||
|
||||
setCharPref(name, value) {
|
||||
if (!(name in this.oldValues)) {
|
||||
this.oldValues[name] = Services.prefs.getCharPref(name);
|
||||
}
|
||||
Services.prefs.setCharPref(name, value);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const name of Object.keys(this.oldValues)) {
|
||||
Services.prefs.setCharPref(name, this.oldValues[name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
load("utils.js"); /* globals withMockPreferences */
|
||||
|
||||
function withServer(server, task) {
|
||||
return function* inner() {
|
||||
return withMockPreferences(async function inner(preferences) {
|
||||
const serverUrl = `http://localhost:${server.identity.primaryPort}`;
|
||||
const prefManager = new PrefManager();
|
||||
prefManager.setCharPref("extensions.shield-recipe-client.api_url", `${serverUrl}/api/v1`);
|
||||
prefManager.setCharPref(
|
||||
preferences.set("extensions.shield-recipe-client.api_url", `${serverUrl}/api/v1`);
|
||||
preferences.set(
|
||||
"security.content.signature.root_hash",
|
||||
// Hash of the key that signs the normandy dev certificates
|
||||
"4C:35:B1:C3:E3:12:D9:55:E7:78:ED:D0:A7:E7:8A:38:83:04:EF:01:BF:FA:03:29:B2:46:9F:3C:C5:EC:36:04"
|
||||
);
|
||||
|
||||
try {
|
||||
yield task(serverUrl);
|
||||
await task(serverUrl);
|
||||
} finally {
|
||||
prefManager.cleanup();
|
||||
yield new Promise(resolve => server.stop(resolve));
|
||||
await new Promise(resolve => server.stop(resolve));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function makeScriptServer(scriptPath) {
|
||||
|
@ -63,7 +43,7 @@ function makeMockApiServer() {
|
|||
const server = new HttpServer();
|
||||
server.registerDirectory("/", do_get_file("mock_api"));
|
||||
|
||||
server.setIndexHandler(Task.async(function* (request, response) {
|
||||
server.setIndexHandler(async function(request, response) {
|
||||
response.processAsync();
|
||||
const dir = request.getProperty("directory");
|
||||
const index = dir.clone();
|
||||
|
@ -77,7 +57,7 @@ function makeMockApiServer() {
|
|||
}
|
||||
|
||||
try {
|
||||
const contents = yield OS.File.read(index.path, {encoding: "utf-8"});
|
||||
const contents = await OS.File.read(index.path, {encoding: "utf-8"});
|
||||
response.write(contents);
|
||||
} catch (e) {
|
||||
response.setStatusLine("1.1", 500, "Server error");
|
||||
|
@ -85,7 +65,7 @@ function makeMockApiServer() {
|
|||
} finally {
|
||||
response.finish();
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
server.start(-1);
|
||||
return server;
|
||||
|
@ -95,62 +75,92 @@ function withMockApiServer(task) {
|
|||
return withServer(makeMockApiServer(), task);
|
||||
}
|
||||
|
||||
add_task(withMockApiServer(function* test_get(serverUrl) {
|
||||
add_task(withMockApiServer(async function test_get(serverUrl) {
|
||||
// Test that NormandyApi can fetch from the test server.
|
||||
const response = yield NormandyApi.get(`${serverUrl}/api/v1`);
|
||||
const data = yield response.json();
|
||||
const response = await NormandyApi.get(`${serverUrl}/api/v1`);
|
||||
const data = await response.json();
|
||||
equal(data["recipe-list"], "/api/v1/recipe/", "Expected data in response");
|
||||
}));
|
||||
|
||||
add_task(withMockApiServer(function* test_getApiUrl(serverUrl) {
|
||||
add_task(withMockApiServer(async function test_getApiUrl(serverUrl) {
|
||||
const apiBase = `${serverUrl}/api/v1`;
|
||||
// Test that NormandyApi can use the self-describing API's index
|
||||
const recipeListUrl = yield NormandyApi.getApiUrl("action-list");
|
||||
const recipeListUrl = await NormandyApi.getApiUrl("action-list");
|
||||
equal(recipeListUrl, `${apiBase}/action/`, "Can retrieve action-list URL from API");
|
||||
}));
|
||||
|
||||
add_task(withMockApiServer(function* test_fetchRecipes() {
|
||||
const recipes = yield NormandyApi.fetchRecipes();
|
||||
add_task(withMockApiServer(async function test_fetchRecipes() {
|
||||
const recipes = await NormandyApi.fetchRecipes();
|
||||
equal(recipes.length, 1);
|
||||
equal(recipes[0].name, "system-addon-test");
|
||||
}));
|
||||
|
||||
add_task(withMockApiServer(function* test_classifyClient() {
|
||||
const classification = yield NormandyApi.classifyClient();
|
||||
add_task(withMockApiServer(async function test_classifyClient() {
|
||||
const classification = await NormandyApi.classifyClient();
|
||||
Assert.deepEqual(classification, {
|
||||
country: "US",
|
||||
request_time: new Date("2017-02-22T17:43:24.657841Z"),
|
||||
});
|
||||
}));
|
||||
|
||||
add_task(withMockApiServer(function* test_fetchAction() {
|
||||
const action = yield NormandyApi.fetchAction("show-heartbeat");
|
||||
equal(action.name, "show-heartbeat");
|
||||
add_task(withMockApiServer(async function test_fetchActions() {
|
||||
const actions = await NormandyApi.fetchActions();
|
||||
equal(actions.length, 2);
|
||||
const actionNames = actions.map(a => a.name);
|
||||
ok(actionNames.includes("console-log"));
|
||||
ok(actionNames.includes("show-heartbeat"));
|
||||
}));
|
||||
|
||||
add_task(withScriptServer("test_server.sjs", function* test_getTestServer(serverUrl) {
|
||||
add_task(withScriptServer("query_server.sjs", async function test_getTestServer(serverUrl) {
|
||||
// Test that NormandyApi can fetch from the test server.
|
||||
const response = yield NormandyApi.get(serverUrl);
|
||||
const data = yield response.json();
|
||||
const response = await NormandyApi.get(serverUrl);
|
||||
const data = await response.json();
|
||||
Assert.deepEqual(data, {queryString: {}, body: {}}, "NormandyApi returned incorrect server data.");
|
||||
}));
|
||||
|
||||
add_task(withScriptServer("test_server.sjs", function* test_getQueryString(serverUrl) {
|
||||
add_task(withScriptServer("query_server.sjs", async function test_getQueryString(serverUrl) {
|
||||
// Test that NormandyApi can send query string parameters to the test server.
|
||||
const response = yield NormandyApi.get(serverUrl, {foo: "bar", baz: "biff"});
|
||||
const data = yield response.json();
|
||||
const response = await NormandyApi.get(serverUrl, {foo: "bar", baz: "biff"});
|
||||
const data = await response.json();
|
||||
Assert.deepEqual(
|
||||
data, {queryString: {foo: "bar", baz: "biff"}, body: {}},
|
||||
"NormandyApi sent an incorrect query string."
|
||||
);
|
||||
}));
|
||||
|
||||
add_task(withScriptServer("test_server.sjs", function* test_postData(serverUrl) {
|
||||
add_task(withScriptServer("query_server.sjs", async function test_postData(serverUrl) {
|
||||
// Test that NormandyApi can POST JSON-formatted data to the test server.
|
||||
const response = yield NormandyApi.post(serverUrl, {foo: "bar", baz: "biff"});
|
||||
const data = yield response.json();
|
||||
const response = await NormandyApi.post(serverUrl, {foo: "bar", baz: "biff"});
|
||||
const data = await response.json();
|
||||
Assert.deepEqual(
|
||||
data, {queryString: {}, body: {foo: "bar", baz: "biff"}},
|
||||
"NormandyApi sent an incorrect query string."
|
||||
);
|
||||
}));
|
||||
|
||||
add_task(withScriptServer("echo_server.sjs", async function test_fetchImplementation(serverUrl) {
|
||||
const action = {
|
||||
implementation_url: `${serverUrl}?status=200&body=testcontent`,
|
||||
};
|
||||
equal(
|
||||
await NormandyApi.fetchImplementation(action),
|
||||
"testcontent",
|
||||
"fetchImplementation fetches the content at the correct URL",
|
||||
);
|
||||
}));
|
||||
|
||||
add_task(withScriptServer(
|
||||
"echo_server.sjs",
|
||||
async function test_fetchImplementationFail(serverUrl) {
|
||||
const action = {
|
||||
implementation_url: `${serverUrl}?status=500&body=servererror`,
|
||||
};
|
||||
|
||||
try {
|
||||
await NormandyApi.fetchImplementation(action);
|
||||
ok(false, "fetchImplementation throws for non-200 response status codes");
|
||||
} catch (err) {
|
||||
// pass
|
||||
}
|
||||
},
|
||||
));
|
||||
|
|
|
@ -4,27 +4,44 @@
|
|||
|
||||
Cu.import("resource://shield-recipe-client/lib/Sampling.jsm", this);
|
||||
|
||||
add_task(function* testStableSample() {
|
||||
add_task(async function testStableSample() {
|
||||
// Absolute samples
|
||||
equal(yield Sampling.stableSample("test", 1), true, "stableSample returns true for 100% sample");
|
||||
equal(yield Sampling.stableSample("test", 0), false, "stableSample returns false for 0% sample");
|
||||
equal(await Sampling.stableSample("test", 1), true, "stableSample returns true for 100% sample");
|
||||
equal(await Sampling.stableSample("test", 0), false, "stableSample returns false for 0% sample");
|
||||
|
||||
// Known samples. The numbers are nonces to make the tests pass
|
||||
equal(yield Sampling.stableSample("test-0", 0.5), true, "stableSample returns true for known matching sample");
|
||||
equal(yield Sampling.stableSample("test-1", 0.5), false, "stableSample returns false for known non-matching sample");
|
||||
equal(await Sampling.stableSample("test-0", 0.5), true, "stableSample returns true for known matching sample");
|
||||
equal(await Sampling.stableSample("test-1", 0.5), false, "stableSample returns false for known non-matching sample");
|
||||
});
|
||||
|
||||
add_task(function* testBucketSample() {
|
||||
add_task(async function testBucketSample() {
|
||||
// Absolute samples
|
||||
equal(yield Sampling.bucketSample("test", 0, 10, 10), true, "bucketSample returns true for 100% sample");
|
||||
equal(yield Sampling.bucketSample("test", 0, 0, 10), false, "bucketSample returns false for 0% sample");
|
||||
equal(await Sampling.bucketSample("test", 0, 10, 10), true, "bucketSample returns true for 100% sample");
|
||||
equal(await Sampling.bucketSample("test", 0, 0, 10), false, "bucketSample returns false for 0% sample");
|
||||
|
||||
// Known samples. The numbers are nonces to make the tests pass
|
||||
equal(yield Sampling.bucketSample("test-0", 0, 5, 10), true, "bucketSample returns true for known matching sample");
|
||||
equal(yield Sampling.bucketSample("test-1", 0, 5, 10), false, "bucketSample returns false for known non-matching sample");
|
||||
equal(await Sampling.bucketSample("test-0", 0, 5, 10), true, "bucketSample returns true for known matching sample");
|
||||
equal(await Sampling.bucketSample("test-1", 0, 5, 10), false, "bucketSample returns false for known non-matching sample");
|
||||
});
|
||||
|
||||
add_task(function* testFractionToKey() {
|
||||
add_task(async function testRatioSample() {
|
||||
// Invalid input
|
||||
Assert.rejects(Sampling.ratioSample("test", []), "ratioSample rejects for a list with no ratios");
|
||||
|
||||
// Absolute samples
|
||||
equal(await Sampling.ratioSample("test", [1]), 0, "ratioSample returns 0 for a list with only 1 ratio");
|
||||
equal(
|
||||
await Sampling.ratioSample("test", [0, 0, 1, 0]),
|
||||
2,
|
||||
"ratioSample returns the only non-zero bucket if all other buckets are zero"
|
||||
);
|
||||
|
||||
// Known samples. The numbers are nonces to make the tests pass
|
||||
equal(await Sampling.ratioSample("test-0", [1, 1]), 0, "ratioSample returns the correct index for known matching sample");
|
||||
equal(await Sampling.ratioSample("test-1", [1, 1]), 1, "ratioSample returns the correct index for known non-matching sample");
|
||||
});
|
||||
|
||||
add_task(async function testFractionToKey() {
|
||||
// Test that results are always 12 character hexadecimal strings.
|
||||
const expected_regex = /[0-9a-f]{12}/;
|
||||
const count = 100;
|
||||
|
@ -38,12 +55,12 @@ add_task(function* testFractionToKey() {
|
|||
equal(successes, count, "fractionToKey makes keys the right length");
|
||||
});
|
||||
|
||||
add_task(function* testTruncatedHash() {
|
||||
add_task(async function testTruncatedHash() {
|
||||
const expected_regex = /[0-9a-f]{12}/;
|
||||
const count = 100;
|
||||
let successes = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const h = yield Sampling.truncatedHash(Math.random());
|
||||
const h = await Sampling.truncatedHash(Math.random());
|
||||
if (expected_regex.test(h)) {
|
||||
successes++;
|
||||
}
|
||||
|
@ -51,7 +68,7 @@ add_task(function* testTruncatedHash() {
|
|||
equal(successes, count, "truncatedHash makes hashes the right length");
|
||||
});
|
||||
|
||||
add_task(function* testBufferToHex() {
|
||||
add_task(async function testBufferToHex() {
|
||||
const data = new ArrayBuffer(4);
|
||||
const view = new DataView(data);
|
||||
view.setUint8(0, 0xff);
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
"use strict";
|
||||
|
||||
Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm");
|
||||
|
||||
// wrapAsync should wrap privileged Promises with Promises that are usable by
|
||||
// the sandbox.
|
||||
add_task(function* () {
|
||||
const manager = new SandboxManager();
|
||||
manager.addHold("testing");
|
||||
|
||||
manager.cloneIntoGlobal("driver", {
|
||||
async privileged() {
|
||||
return "privileged";
|
||||
},
|
||||
wrapped: manager.wrapAsync(async function() {
|
||||
return "wrapped";
|
||||
}),
|
||||
aValue: "aValue",
|
||||
wrappedThis: manager.wrapAsync(async function() {
|
||||
return this.aValue;
|
||||
}),
|
||||
}, {cloneFunctions: true});
|
||||
|
||||
// Assertion helpers
|
||||
manager.addGlobal("ok", ok);
|
||||
manager.addGlobal("equal", equal);
|
||||
|
||||
const sandboxResult = yield new Promise(resolve => {
|
||||
manager.addGlobal("resolve", result => resolve(result));
|
||||
manager.evalInSandbox(`
|
||||
// Unwrapped privileged promises are not accessible in the sandbox
|
||||
try {
|
||||
const privilegedResult = driver.privileged().then(() => false);
|
||||
ok(false, "The sandbox could not use a privileged Promise");
|
||||
} catch (err) { }
|
||||
|
||||
// Wrapped functions return promises that the sandbox can access.
|
||||
const wrappedResult = driver.wrapped();
|
||||
ok("then" in wrappedResult);
|
||||
|
||||
// Resolve the Promise around the sandbox with the wrapped result to test
|
||||
// that the Promise in the sandbox works.
|
||||
wrappedResult.then(resolve);
|
||||
`);
|
||||
});
|
||||
equal(sandboxResult, "wrapped", "wrapAsync methods return Promises that work in the sandbox");
|
||||
|
||||
yield manager.evalInSandbox(`
|
||||
(async function sandboxTest() {
|
||||
equal(
|
||||
await driver.wrappedThis(),
|
||||
"aValue",
|
||||
"wrapAsync preserves the behavior of the this keyword",
|
||||
);
|
||||
})();
|
||||
`);
|
||||
|
||||
manager.removeHold("testing");
|
||||
});
|
||||
|
||||
// wrapAsync cloning options
|
||||
add_task(function* () {
|
||||
const manager = new SandboxManager();
|
||||
manager.addHold("testing");
|
||||
|
||||
// clonedArgument stores the argument passed to cloned(), which we use to test
|
||||
// that arguments from within the sandbox are cloned outside.
|
||||
let clonedArgument = null;
|
||||
manager.cloneIntoGlobal("driver", {
|
||||
uncloned: manager.wrapAsync(async function() {
|
||||
return {value: "uncloned"};
|
||||
}),
|
||||
cloned: manager.wrapAsync(async function(argument) {
|
||||
clonedArgument = argument;
|
||||
return {value: "cloned"};
|
||||
}, {cloneInto: true, cloneArguments: true}),
|
||||
}, {cloneFunctions: true});
|
||||
|
||||
// Assertion helpers
|
||||
manager.addGlobal("ok", ok);
|
||||
manager.addGlobal("deepEqual", deepEqual);
|
||||
|
||||
yield new Promise(resolve => {
|
||||
manager.addGlobal("resolve", resolve);
|
||||
manager.evalInSandbox(`
|
||||
(async function() {
|
||||
// The uncloned return value should be privileged and inaccesible.
|
||||
const uncloned = await driver.uncloned();
|
||||
ok(!("value" in uncloned), "The sandbox could not use an uncloned return value");
|
||||
|
||||
// The cloned return value should be usable.
|
||||
deepEqual(
|
||||
await driver.cloned({value: "insidesandbox"}),
|
||||
{value: "cloned"},
|
||||
"The sandbox could use the cloned return value",
|
||||
);
|
||||
})().then(resolve);
|
||||
`);
|
||||
});
|
||||
|
||||
// Removing the hold nukes the sandbox. Afterwards, because cloned() has the
|
||||
// cloneArguments option, the clonedArgument variable should still be
|
||||
// accessible.
|
||||
manager.removeHold("testing");
|
||||
deepEqual(
|
||||
clonedArgument,
|
||||
{value: "insidesandbox"},
|
||||
"cloneArguments allowed an argument from within the sandbox to persist after it was nuked",
|
||||
);
|
||||
});
|
|
@ -0,0 +1,21 @@
|
|||
"use strict";
|
||||
/* globals Cu */
|
||||
|
||||
Cu.import("resource://shield-recipe-client/lib/Utils.jsm");
|
||||
|
||||
add_task(async function testKeyBy() {
|
||||
const list = [];
|
||||
deepEqual(Utils.keyBy(list, "foo"), {});
|
||||
|
||||
const foo = {name: "foo", value: 1};
|
||||
list.push(foo);
|
||||
deepEqual(Utils.keyBy(list, "name"), {foo});
|
||||
|
||||
const bar = {name: "bar", value: 2};
|
||||
list.push(bar);
|
||||
deepEqual(Utils.keyBy(list, "name"), {foo, bar});
|
||||
|
||||
const missingKey = {value: 7};
|
||||
list.push(missingKey);
|
||||
deepEqual(Utils.keyBy(list, "name"), {foo, bar}, "keyBy skips items that are missing the key");
|
||||
});
|
|
@ -0,0 +1,51 @@
|
|||
"use strict";
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
|
||||
const preferenceBranches = {
|
||||
user: Preferences,
|
||||
default: new Preferences({defaultBranch: true}),
|
||||
};
|
||||
|
||||
// duplicated from test/browser/head.js until we move everything over to mochitests.
|
||||
function withMockPreferences(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])) {
|
||||
this.oldValues[branch][name] = preferenceBranches[branch].get(name, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const [branchName, values] of Object.entries(this.oldValues)) {
|
||||
const preferenceBranch = preferenceBranches[branchName];
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
if (value !== undefined) {
|
||||
preferenceBranch.set(name, value);
|
||||
} else {
|
||||
preferenceBranch.reset(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
"use strict";
|
||||
|
||||
const {interfaces: Ci, utils: Cu} = Components;
|
||||
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
|
||||
|
@ -17,3 +17,12 @@ if (!extensionDir.exists()) {
|
|||
extensionDir.append(EXTENSION_ID + ".xpi");
|
||||
}
|
||||
Components.manager.addBootstrappedManifestLocation(extensionDir);
|
||||
|
||||
// Load Sinon for mocking/stubbing during tests.
|
||||
// Sinon assumes that setTimeout and friends are available, and looks for a
|
||||
// global object named self during initialization.
|
||||
Cu.import("resource://gre/modules/Timer.jsm");
|
||||
const self = {}; // eslint-disable-line no-unused-vars
|
||||
|
||||
const loader = Cc["@mozilla.org/moz/jssubscript-loader;1"].getService(Ci.mozIJSSubScriptLoader);
|
||||
loader.loadSubScript("resource://testing-common/sinon-1.16.1.js");
|
||||
|
|
|
@ -2,7 +2,10 @@
|
|||
head = xpc_head.js
|
||||
support-files =
|
||||
mock_api/**
|
||||
test_server.sjs
|
||||
query_server.sjs
|
||||
echo_server.sjs
|
||||
utils.js
|
||||
|
||||
[test_NormandyApi.js]
|
||||
[test_Sampling.js]
|
||||
[test_SandboxManager.js]
|
||||
|
|
Загрузка…
Ссылка в новой задаче