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:
Michael Kelly 2017-05-02 11:06:10 -07:00
Родитель 9a6937d9cb
Коммит 8b3f66fa72
42 изменённых файлов: 2700 добавлений и 536 удалений

Просмотреть файл

@ -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]