--- a/toolkit/components/normandy/Normandy.jsm
+++ b/toolkit/components/normandy/Normandy.jsm
@@ -9,58 +9,63 @@ ChromeUtils.import("resource://gre/modul
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetters(this, {
AboutPages: "resource://normandy-content/AboutPages.jsm",
AddonStudies: "resource://normandy/lib/AddonStudies.jsm",
CleanupManager: "resource://normandy/lib/CleanupManager.jsm",
LogManager: "resource://normandy/lib/LogManager.jsm",
PreferenceExperiments: "resource://normandy/lib/PreferenceExperiments.jsm",
+ PreferenceRollouts: "resource://normandy/lib/PreferenceRollouts.jsm",
RecipeRunner: "resource://normandy/lib/RecipeRunner.jsm",
ShieldPreferences: "resource://normandy/lib/ShieldPreferences.jsm",
TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
});
var EXPORTED_SYMBOLS = ["Normandy"];
const UI_AVAILABLE_NOTIFICATION = "sessionstore-windows-restored";
const BOOTSTRAP_LOGGER_NAME = "app.normandy.bootstrap";
const SHIELD_INIT_NOTIFICATION = "shield-init-complete";
const PREF_PREFIX = "app.normandy";
const LEGACY_PREF_PREFIX = "extensions.shield-recipe-client";
const STARTUP_EXPERIMENT_PREFS_BRANCH = `${PREF_PREFIX}.startupExperimentPrefs.`;
+const STARTUP_ROLLOUT_PREFS_BRANCH = `${PREF_PREFIX}.startupRolloutPrefs.`;
const PREF_LOGGING_LEVEL = `${PREF_PREFIX}.logging.level`;
// Logging
const log = Log.repository.getLogger(BOOTSTRAP_LOGGER_NAME);
log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
log.level = Services.prefs.getIntPref(PREF_LOGGING_LEVEL, Log.Level.Warn);
-let studyPrefsChanged = {};
+var Normandy = {
+ studyPrefsChanged: {},
+ rolloutPrefsChanged: {},
-var Normandy = {
init() {
// Initialization that needs to happen before the first paint on startup.
this.migrateShieldPrefs();
- this.initExperimentPrefs();
+ this.rolloutPrefsChanged = this.applyStartupPrefs(STARTUP_ROLLOUT_PREFS_BRANCH);
+ this.studyPrefsChanged = this.applyStartupPrefs(STARTUP_EXPERIMENT_PREFS_BRANCH);
// Wait until the UI is available before finishing initialization.
Services.obs.addObserver(this, UI_AVAILABLE_NOTIFICATION);
},
observe(subject, topic, data) {
if (topic === UI_AVAILABLE_NOTIFICATION) {
Services.obs.removeObserver(this, UI_AVAILABLE_NOTIFICATION);
this.finishInit();
}
},
async finishInit() {
- await PreferenceExperiments.recordOriginalValues(studyPrefsChanged);
+ await PreferenceRollouts.recordOriginalValues(this.rolloutPrefsChanged);
+ await PreferenceExperiments.recordOriginalValues(this.studyPrefsChanged);
// Setup logging and listen for changes to logging prefs
LogManager.configure(Services.prefs.getIntPref(PREF_LOGGING_LEVEL, Log.Level.Warn));
Services.prefs.addObserver(PREF_LOGGING_LEVEL, LogManager.configure);
CleanupManager.addCleanupHandler(
() => Services.prefs.removeObserver(PREF_LOGGING_LEVEL, LogManager.configure),
);
@@ -78,16 +83,22 @@ var Normandy = {
try {
await AddonStudies.init();
} catch (err) {
log.error("Failed to initialize addon studies:", err);
}
try {
+ await PreferenceRollouts.init();
+ } catch (err) {
+ log.error("Failed to initialize preference rollouts:", err);
+ }
+
+ try {
await PreferenceExperiments.init();
} catch (err) {
log.error("Failed to initialize preference experiments:", err);
}
try {
ShieldPreferences.init();
} catch (err) {
@@ -96,16 +107,17 @@ var Normandy = {
await RecipeRunner.init();
Services.obs.notifyObservers(null, SHIELD_INIT_NOTIFICATION);
},
async uninit() {
await CleanupManager.cleanup();
Services.prefs.removeObserver(PREF_LOGGING_LEVEL, LogManager.configure);
+ await PreferenceRollouts.uninit();
// In case the observer didn't run, clean it up.
try {
Services.obs.removeObserver(this, UI_AVAILABLE_NOTIFICATION);
} catch (err) {
// It must already be removed!
}
},
@@ -147,81 +159,90 @@ var Normandy = {
// This should never happen either.
log.error(`Error getting startup pref ${prefName}; unknown value type ${legacyPrefType}.`);
}
legacyBranch.clearUserPref(prefName);
}
},
- initExperimentPrefs() {
- studyPrefsChanged = {};
- const defaultBranch = Services.prefs.getDefaultBranch("");
- const experimentBranch = Services.prefs.getBranch(STARTUP_EXPERIMENT_PREFS_BRANCH);
+ /**
+ * Copy a preference subtree from one branch to another, being careful about
+ * types, and return the values the target branch originally had. Prefs will
+ * be read from the user branch and applied to the default branch.
+ * @param sourcePrefix
+ * The pref prefix to read prefs from.
+ * @returns
+ * The original values that each pref had on the default branch.
+ */
+ applyStartupPrefs(sourcePrefix) {
+ const originalValues = {};
+ const sourceBranch = Services.prefs.getBranch(sourcePrefix);
+ const targetBranch = Services.prefs.getDefaultBranch("");
- for (const prefName of experimentBranch.getChildList("")) {
- const experimentPrefType = experimentBranch.getPrefType(prefName);
- const realPrefType = defaultBranch.getPrefType(prefName);
+ for (const prefName of sourceBranch.getChildList("")) {
+ const sourcePrefType = sourceBranch.getPrefType(prefName);
+ const targetPrefType = targetBranch.getPrefType(prefName);
- if (realPrefType !== Services.prefs.PREF_INVALID && realPrefType !== experimentPrefType) {
- log.error(`Error setting startup pref ${prefName}; pref type does not match.`);
+ if (targetPrefType !== Services.prefs.PREF_INVALID && targetPrefType !== sourcePrefType) {
+ Cu.reportError(new Error(`Error setting startup pref ${prefName}; pref type does not match.`));
continue;
}
// record the value of the default branch before setting it
try {
- switch (realPrefType) {
- case Services.prefs.PREF_STRING:
- studyPrefsChanged[prefName] = defaultBranch.getCharPref(prefName);
+ switch (targetPrefType) {
+ case Services.prefs.PREF_STRING: {
+ originalValues[prefName] = targetBranch.getCharPref(prefName);
break;
-
- case Services.prefs.PREF_INT:
- studyPrefsChanged[prefName] = defaultBranch.getIntPref(prefName);
+ }
+ case Services.prefs.PREF_INT: {
+ originalValues[prefName] = targetBranch.getIntPref(prefName);
break;
-
- case Services.prefs.PREF_BOOL:
- studyPrefsChanged[prefName] = defaultBranch.getBoolPref(prefName);
+ }
+ case Services.prefs.PREF_BOOL: {
+ originalValues[prefName] = targetBranch.getBoolPref(prefName);
break;
-
- case Services.prefs.PREF_INVALID:
- studyPrefsChanged[prefName] = null;
+ }
+ case Services.prefs.PREF_INVALID: {
+ originalValues[prefName] = null;
break;
-
- default:
+ }
+ default: {
// This should never happen
- log.error(`Error getting startup pref ${prefName}; unknown value type ${experimentPrefType}.`);
+ log.error(`Error getting startup pref ${prefName}; unknown value type ${sourcePrefType}.`);
+ }
}
} catch (e) {
if (e.result === Cr.NS_ERROR_UNEXPECTED) {
// There is a value for the pref on the user branch but not on the default branch. This is ok.
- studyPrefsChanged[prefName] = null;
+ originalValues[prefName] = null;
} else {
- // rethrow
- throw e;
+ // Unexpected error, report it and move on
+ Cu.reportError(e);
+ continue;
}
}
// now set the new default value
- switch (experimentPrefType) {
- case Services.prefs.PREF_STRING:
- defaultBranch.setCharPref(prefName, experimentBranch.getCharPref(prefName));
+ switch (sourcePrefType) {
+ case Services.prefs.PREF_STRING: {
+ targetBranch.setCharPref(prefName, sourceBranch.getCharPref(prefName));
break;
-
- case Services.prefs.PREF_INT:
- defaultBranch.setIntPref(prefName, experimentBranch.getIntPref(prefName));
+ }
+ case Services.prefs.PREF_INT: {
+ targetBranch.setIntPref(prefName, sourceBranch.getIntPref(prefName));
break;
-
- case Services.prefs.PREF_BOOL:
- defaultBranch.setBoolPref(prefName, experimentBranch.getBoolPref(prefName));
+ }
+ case Services.prefs.PREF_BOOL: {
+ targetBranch.setBoolPref(prefName, sourceBranch.getBoolPref(prefName));
break;
-
- case Services.prefs.PREF_INVALID:
+ }
+ default: {
// This should never happen.
- log.error(`Error setting startup pref ${prefName}; pref type is invalid (${experimentPrefType}).`);
- break;
-
- default:
- // This should never happen either.
- log.error(`Error getting startup pref ${prefName}; unknown value type ${experimentPrefType}.`);
+ Cu.reportError(new Error(`Error getting startup pref ${prefName}; unexpected value type ${sourcePrefType}.`));
+ }
}
}
+
+ return originalValues;
},
};
--- a/toolkit/components/normandy/actions/BaseAction.jsm
+++ b/toolkit/components/normandy/actions/BaseAction.jsm
@@ -23,17 +23,18 @@ class BaseAction {
this.finalized = false;
this.failed = false;
this.log = LogManager.getLogger(`action.${this.name}`);
try {
this._preExecution();
} catch (err) {
this.failed = true;
- this.log.error(`Could not initialize action ${this.name}: ${err}`);
+ err.message = `Could not initialize action ${this.name}: ${err.message}`;
+ Cu.reportError(err);
Uptake.reportAction(this.name, Uptake.ACTION_PRE_EXECUTION_ERROR);
}
}
get schema() {
return {
type: "object",
properties: {},
@@ -80,17 +81,17 @@ class BaseAction {
}
recipe.arguments = validatedArguments;
let status = Uptake.RECIPE_SUCCESS;
try {
await this._run(recipe);
} catch (err) {
- this.log.error(`Could not execute recipe ${recipe.name}: ${err}`);
+ Cu.reportError(err);
status = Uptake.RECIPE_EXECUTION_ERROR;
}
Uptake.reportRecipe(recipe.id, status);
}
/**
* Action specific recipe behavior must be implemented here. It
* will be executed once for reach recipe, being passed the recipe
@@ -112,27 +113,28 @@ class BaseAction {
if (this.failed) {
this.log.info(`Skipping post-execution hook for ${this.name} due to earlier failure.`);
return;
}
let status = Uptake.ACTION_SUCCESS;
try {
- this._finalize();
+ await this._finalize();
} catch (err) {
status = Uptake.ACTION_POST_EXECUTION_ERROR;
- this.log.info(`Could not run postExecution hook for ${this.name}: ${err.message}`);
+ err.message = `Could not run postExecution hook for ${this.name}: ${err.message}`;
+ Cu.reportError(err);
} finally {
this.finalized = true;
Uptake.reportAction(this.name, status);
}
}
/**
* Action specific post-execution behavior should be implemented
* here. It will be executed once after all recipes have been
* processed.
*/
- _finalize() {
+ async _finalize() {
// Does nothing, may be overridden
}
}
rename from toolkit/components/normandy/actions/ConsoleLog.jsm
rename to toolkit/components/normandy/actions/ConsoleLogAction.jsm
--- a/toolkit/components/normandy/actions/ConsoleLog.jsm
+++ b/toolkit/components/normandy/actions/ConsoleLogAction.jsm
@@ -2,19 +2,19 @@
* 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";
ChromeUtils.import("resource://normandy/actions/BaseAction.jsm");
ChromeUtils.defineModuleGetter(this, "ActionSchemas", "resource://normandy/actions/schemas/index.js");
-var EXPORTED_SYMBOLS = ["ConsoleLog"];
+var EXPORTED_SYMBOLS = ["ConsoleLogAction"];
-class ConsoleLog extends BaseAction {
+class ConsoleLogAction extends BaseAction {
get schema() {
- return ActionSchemas.consoleLog;
+ return ActionSchemas["console-log"];
}
async _run(recipe) {
this.log.info(recipe.arguments.message);
}
}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/actions/PreferenceRolloutAction.jsm
@@ -0,0 +1,167 @@
+/* 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";
+
+ChromeUtils.import("resource://normandy/actions/BaseAction.jsm");
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.defineModuleGetter(this, "IndexedDB", "resource://gre/modules/IndexedDB.jsm");
+ChromeUtils.defineModuleGetter(this, "TelemetryEnvironment", "resource://gre/modules/TelemetryEnvironment.jsm");
+ChromeUtils.defineModuleGetter(this, "PreferenceRollouts", "resource://normandy/lib/PreferenceRollouts.jsm");
+ChromeUtils.defineModuleGetter(this, "PrefUtils", "resource://normandy/lib/PrefUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "ActionSchemas", "resource://normandy/actions/schemas/index.js");
+ChromeUtils.defineModuleGetter(this, "TelemetryEvents", "resource://normandy/lib/TelemetryEvents.jsm");
+
+var EXPORTED_SYMBOLS = ["PreferenceRolloutAction"];
+
+const PREFERENCE_TYPE_MAP = {
+ boolean: Services.prefs.PREF_BOOL,
+ string: Services.prefs.PREF_STRING,
+ number: Services.prefs.PREF_INT,
+};
+
+class PreferenceRolloutAction extends BaseAction {
+ get schema() {
+ return ActionSchemas["preference-rollout"];
+ }
+
+ async _run(recipe) {
+ const args = recipe.arguments;
+
+ // First determine which preferences are already being managed, to avoid
+ // conflicts between recipes. This will throw if there is a problem.
+ await this._verifyRolloutPrefs(args);
+
+ const newRollout = {
+ slug: args.slug,
+ state: "active",
+ preferences: args.preferences.map(({preferenceName, value}) => ({
+ preferenceName,
+ value,
+ previousValue: null,
+ })),
+ };
+
+ const existingRollout = await PreferenceRollouts.get(args.slug);
+ if (existingRollout) {
+ const anyChanged = await this._updatePrefsForExistingRollout(existingRollout, newRollout);
+
+ // If anything was different about the new rollout, write it to the db and send an event about it
+ if (anyChanged) {
+ await PreferenceRollouts.update(newRollout);
+ TelemetryEvents.sendEvent("update", "preference_rollout", args.slug, {previousState: existingRollout.state});
+
+ switch (existingRollout.state) {
+ case PreferenceRollouts.STATE_ACTIVE: {
+ this.log.debug(`Updated preference rollout ${args.slug}`);
+ break;
+ }
+ case PreferenceRollouts.STATE_GRADUATED: {
+ this.log.debug(`Ungraduated preference rollout ${args.slug}`);
+ TelemetryEnvironment.setExperimentActive(args.slug, newRollout.state, {type: "normandy-prefrollout"});
+ break;
+ }
+ default: {
+ Cu.reportError(new Error(`Updated pref rollout in unexpected state: ${existingRollout.state}`));
+ }
+ }
+ } else {
+ this.log.debug(`No updates to preference rollout ${args.slug}`);
+ }
+
+ } else { // new enrollment
+ for (const prefSpec of newRollout.preferences) {
+ prefSpec.previousValue = PrefUtils.getPref("default", prefSpec.preferenceName);
+ }
+ await PreferenceRollouts.add(newRollout);
+
+ for (const {preferenceName, value} of args.preferences) {
+ PrefUtils.setPref("default", preferenceName, value);
+ }
+
+ this.log.debug(`Enrolled in preference rollout ${args.slug}`);
+ TelemetryEnvironment.setExperimentActive(args.slug, newRollout.state, {type: "normandy-prefrollout"});
+ TelemetryEvents.sendEvent("enroll", "preference_rollout", args.slug, {});
+ }
+ }
+
+ /**
+ * Check that all the preferences in a rollout are ok to set. This means 1) no
+ * other rollout is managing them, and 2) they match the types of the builtin
+ * values.
+ * @param {PreferenceRollout} rollout The arguments from a rollout recipe.
+ * @throws If the preferences are not valid, with details in the error message.
+ */
+ async _verifyRolloutPrefs({slug, preferences}) {
+ const existingManagedPrefs = new Set();
+ for (const rollout of await PreferenceRollouts.getAllActive()) {
+ if (rollout.slug === slug) {
+ continue;
+ }
+ for (const prefSpec of rollout.preferences) {
+ existingManagedPrefs.add(prefSpec.preferenceName);
+ }
+ }
+
+ for (const prefSpec of preferences) {
+ if (existingManagedPrefs.has(prefSpec.preferenceName)) {
+ TelemetryEvents.sendEvent("enrollFailed", "preference_rollout", slug, {reason: "conflict", preference: prefSpec.preferenceName});
+ throw new Error(`Cannot start rollout ${slug}. Preference ${prefSpec.preferenceName} is already managed.`);
+ }
+ const existingPrefType = Services.prefs.getPrefType(prefSpec.preferenceName);
+ const rolloutPrefType = PREFERENCE_TYPE_MAP[typeof prefSpec.value];
+
+ if (existingPrefType !== Services.prefs.PREF_INVALID && existingPrefType !== rolloutPrefType) {
+ TelemetryEvents.sendEvent(
+ "enrollFailed",
+ "preference_rollout",
+ slug,
+ {reason: "invalid type", pref: prefSpec.preferenceName},
+ );
+ throw new Error(
+ `Cannot start rollout "${slug}" on "${prefSpec.preferenceName}". ` +
+ `Existing preference is of type ${existingPrefType}, but rollout ` +
+ `specifies type ${rolloutPrefType}`
+ );
+ }
+ }
+ }
+
+ async _updatePrefsForExistingRollout(existingRollout, newRollout) {
+ let anyChanged = false;
+ const oldPrefSpecs = new Map(existingRollout.preferences.map(p => [p.preferenceName, p]));
+ const newPrefSpecs = new Map(newRollout.preferences.map(p => [p.preferenceName, p]));
+
+ // Check for any preferences that no longer exist, and un-set them.
+ for (const {preferenceName, previousValue} of oldPrefSpecs.values()) {
+ if (!newPrefSpecs.has(preferenceName)) {
+ anyChanged = true;
+ PrefUtils.setPref("default", preferenceName, previousValue);
+ }
+ }
+
+ // Check for any preferences that are new and need added, or changed and need updated.
+ for (const prefSpec of Object.values(newRollout.preferences)) {
+ let oldValue = null;
+ if (oldPrefSpecs.has(prefSpec.preferenceName)) {
+ let oldPrefSpec = oldPrefSpecs.get(prefSpec.preferenceName);
+ if (oldPrefSpec.previousValue !== prefSpec.previousValue) {
+ prefSpec.previousValue = oldPrefSpec.previousValue;
+ anyChanged = true;
+ }
+ oldValue = oldPrefSpec.value;
+ }
+ if (oldValue !== newPrefSpecs.get(prefSpec.preferenceName).value) {
+ anyChanged = true;
+ PrefUtils.setPref("default", prefSpec.preferenceName, prefSpec.value);
+ }
+ }
+ return anyChanged;
+ }
+
+ async _finalize() {
+ await PreferenceRollouts.saveStartupPrefs();
+ await PreferenceRollouts.closeDB();
+ }
+}
--- a/toolkit/components/normandy/actions/schemas/index.js
+++ b/toolkit/components/normandy/actions/schemas/index.js
@@ -1,24 +1,56 @@
var EXPORTED_SYMBOLS = ["ActionSchemas"];
const ActionSchemas = {
- consoleLog: {
- "$schema": "http://json-schema.org/draft-04/schema#",
- "title": "Log a message to the console",
- "type": "object",
- "required": [
- "message"
- ],
- "properties": {
- "message": {
- "description": "Message to log to the console",
- "type": "string",
- "default": ""
- }
- }
- }
+ "console-log": {
+ $schema: "http://json-schema.org/draft-04/schema#",
+ title: "Log a message to the console",
+ type: "object",
+ required: ["message"],
+ properties: {
+ message: {
+ description: "Message to log to the console",
+ type: "string",
+ default: "",
+ },
+ },
+ },
+
+ "preference-rollout": {
+ $schema: "http://json-schema.org/draft-04/schema#",
+ title: "Change preferences permanently",
+ type: "object",
+ required: ["slug", "preferences"],
+ properties: {
+ slug: {
+ description: "Unique identifer for the rollout, used in telemetry and rollbacks",
+ type: "string",
+ pattern: "^[a-z0-9\\-_]+$",
+ },
+ preferences: {
+ description: "The preferences to change, and their values",
+ type: "array",
+ minItems: 1,
+ items: {
+ type: "object",
+ required: ["preferenceName", "value"],
+ properties: {
+ preferenceName: {
+ "description": "Full dotted-path of the preference being changed",
+ "type": "string",
+ },
+ value: {
+ description: "Value to set the preference to",
+ type: ["string", "number", "boolean"],
+ },
+ },
+ },
+ },
+ },
+ },
};
+// If running in Node.js, export the schemas.
if (typeof module !== "undefined") {
/* globals module */
module.exports = ActionSchemas;
}
--- a/toolkit/components/normandy/jar.mn
+++ b/toolkit/components/normandy/jar.mn
@@ -2,17 +2,16 @@
# 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/.
toolkit.jar:
% resource normandy %res/normandy/
res/normandy/Normandy.jsm (./Normandy.jsm)
res/normandy/lib/ (./lib/*)
res/normandy/skin/ (./skin/*)
- res/normandy/actions/BaseAction.jsm (./actions/BaseAction.jsm)
- res/normandy/actions/ConsoleLog.jsm (./actions/ConsoleLog.jsm)
+ res/normandy/actions/ (./actions/*.jsm)
res/normandy/actions/schemas/index.js (./actions/schemas/index.js)
% resource normandy-content %res/normandy/content/ contentaccessible=yes
res/normandy/content/ (./content/*)
% resource normandy-vendor %res/normandy/vendor/ contentaccessible=yes
res/normandy/vendor/ (./vendor/*)
--- a/toolkit/components/normandy/lib/ActionSandboxManager.jsm
+++ b/toolkit/components/normandy/lib/ActionSandboxManager.jsm
@@ -5,18 +5,16 @@
"use strict";
ChromeUtils.import("resource://normandy/lib/NormandyDriver.jsm");
ChromeUtils.import("resource://normandy/lib/SandboxManager.jsm");
ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
var 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.
*
@@ -61,17 +59,16 @@ var ActionSandboxManager = class extends
* 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);
const result = await this.evalInSandbox(`
asyncCallbacks.get("${callbackName}")(sandboxedDriver, ...callbackArgs);
`);
return Cu.cloneInto(result, {});
--- a/toolkit/components/normandy/lib/ActionsManager.jsm
+++ b/toolkit/components/normandy/lib/ActionsManager.jsm
@@ -1,16 +1,17 @@
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
XPCOMUtils.defineLazyModuleGetters(this, {
ActionSandboxManager: "resource://normandy/lib/ActionSandboxManager.jsm",
- ConsoleLog: "resource://normandy/actions/ConsoleLog.jsm",
NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
Uptake: "resource://normandy/lib/Uptake.jsm",
+ ConsoleLogAction: "resource://normandy/actions/ConsoleLogAction.jsm",
+ PreferenceRolloutAction: "resource://normandy/actions/PreferenceRolloutAction.jsm",
});
var EXPORTED_SYMBOLS = ["ActionsManager"];
const log = LogManager.getLogger("recipe-runner");
/**
* A class to manage the actions that recipes can use in Normandy.
@@ -22,17 +23,18 @@ const log = LogManager.getLogger("recipe
* client, and manage their lifecycles internally.
*/
class ActionsManager {
constructor() {
this.finalized = false;
this.remoteActionSandboxes = {};
this.localActions = {
- "console-log": new ConsoleLog(),
+ "console-log": new ConsoleLogAction(),
+ "preference-rollout": new PreferenceRolloutAction(),
};
}
async fetchRemoteActions() {
const actions = await NormandyApi.fetchActions();
for (const action of actions) {
// Skip actions with local implementations
@@ -52,16 +54,19 @@ class ActionsManager {
if (/NetworkError/.test(err)) {
status = Uptake.ACTION_NETWORK_ERROR;
} else {
status = Uptake.ACTION_SERVER_ERROR;
}
Uptake.reportAction(action.name, status);
}
}
+
+ const actionNames = Object.keys(this.remoteActionSandboxes);
+ log.debug(`Fetched ${actionNames.length} actions from the server: ${actionNames.join(", ")}`);
}
async preExecution() {
// Local actions run pre-execution hooks implicitly
for (const [actionName, manager] of Object.entries(this.remoteActionSandboxes)) {
try {
await manager.runAsyncCallback("preExecution");
--- a/toolkit/components/normandy/lib/AddonStudies.jsm
+++ b/toolkit/components/normandy/lib/AddonStudies.jsm
@@ -336,18 +336,16 @@ var AddonStudies = {
*/
async stop(recipeId, reason = "unknown") {
const db = await getDatabase();
const study = await getStore(db).get(recipeId);
if (!study) {
throw new Error(`No study found for recipe ${recipeId}.`);
}
if (!study.active) {
- dump(`@@@ Cannot stop study for recipe ${recipeId}; it is already inactive.\n`);
- dump(`@@@\n${new Error().stack}\n@@@\n`);
throw new Error(`Cannot stop study for recipe ${recipeId}; it is already inactive.`);
}
await markAsEnded(db, study, reason);
try {
await Addons.uninstall(study.addonId);
} catch (err) {
--- a/toolkit/components/normandy/lib/NormandyApi.jsm
+++ b/toolkit/components/normandy/lib/NormandyApi.jsm
@@ -125,21 +125,16 @@ var NormandyApi = {
if (!valid) {
throw new NormandyApi.InvalidSignatureError(`${type} signature is not valid`);
}
verifiedObjects.push(object);
}
- log.debug(
- `Fetched ${verifiedObjects.length} ${type} from the server:`,
- verifiedObjects.map(r => r.name).join(", ")
- );
-
return verifiedObjects;
},
/**
* Fetch metadata about this client determined by the server.
* @return {object} Metadata specified by the server
*/
async classifyClient() {
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/lib/PrefUtils.jsm
@@ -0,0 +1,97 @@
+/* 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";
+
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+var EXPORTED_SYMBOLS = ["PrefUtils"];
+
+const kPrefBranches = {
+ user: Services.prefs,
+ default: Services.prefs.getDefaultBranch(""),
+};
+
+var PrefUtils = {
+ /**
+ * Get a preference from the named branch
+ * @param {string} branchName One of "default" or "user"
+ * @param {string} pref
+ * @param {string|boolean|integer|null} [default]
+ * The value to return if the preference does not exist. Defaults to null.
+ */
+ getPref(branchName, pref, defaultValue = null) {
+ const branch = kPrefBranches[branchName];
+ const type = branch.getPrefType(pref);
+ switch (type) {
+ case Services.prefs.PREF_BOOL: {
+ return branch.getBoolPref(pref);
+ }
+ case Services.prefs.PREF_STRING: {
+ return branch.getStringPref(pref);
+ }
+ case Services.prefs.PREF_INT: {
+ return branch.getIntPref(pref);
+ }
+ case Services.prefs.PREF_INVALID: {
+ return defaultValue;
+ }
+ default: {
+ // This should never happen
+ throw new TypeError(`Unknown preference type (${type}) for ${pref}.`);
+ }
+ }
+ },
+
+ /**
+ * Set a preference on the named branch
+ * @param {string} branchName One of "default" or "user"
+ * @param {string} pref
+ * @param {string|boolean|integer|null} value
+ * The value to set. Must match the type named in `type`.
+ */
+ setPref(branchName, pref, value) {
+ if (value === null) {
+ this.clearPref(branchName, pref);
+ return;
+ }
+ const branch = kPrefBranches[branchName];
+ switch (typeof value) {
+ case "boolean": {
+ branch.setBoolPref(pref, value);
+ break;
+ }
+ case "string": {
+ branch.setStringPref(pref, value);
+ break;
+ }
+ case "number": {
+ branch.setIntPref(pref, value);
+ break;
+ }
+ default: {
+ throw new TypeError(`Unexpected value type (${typeof value}) for ${pref}.`);
+ }
+ }
+ },
+
+ /**
+ * Remove a preference from a branch.
+ * @param {string} branchName One of "default" or "user"
+ * @param {string} pref
+ */
+ clearPref(branchName, pref) {
+ if (branchName === "user") {
+ kPrefBranches.user.clearUserPref(pref);
+ } else if (branchName === "default") {
+ // deleteBranch will affect the user branch as well. Get the user-branch
+ // value, and re-set it after clearing the pref.
+ const hadUserValue = Services.prefs.prefHasUserValue(pref);
+ const originalValue = this.getPref("user", pref, null);
+ kPrefBranches.default.deleteBranch(pref);
+ if (hadUserValue) {
+ this.setPref(branchName, pref, originalValue);
+ }
+ }
+ }
+};
--- a/toolkit/components/normandy/lib/PreferenceExperiments.jsm
+++ b/toolkit/components/normandy/lib/PreferenceExperiments.jsm
@@ -504,17 +504,17 @@ var PreferenceExperiments = {
// this point.
Services.prefs.getDefaultBranch("").deleteBranch(preferenceName);
}
}
experiment.expired = true;
store.saveSoon();
- TelemetryEnvironment.setExperimentInactive(experimentName, experiment.branch);
+ TelemetryEnvironment.setExperimentInactive(experimentName);
TelemetryEvents.sendEvent("unenroll", "preference_study", experimentName, {
didResetValue: resetValue ? "true" : "false",
reason,
});
await this.saveStartupPrefs();
},
/**
@@ -547,19 +547,17 @@ var PreferenceExperiments = {
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.
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/lib/PreferenceRollouts.jsm
@@ -0,0 +1,243 @@
+/* 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";
+
+ChromeUtils.import("resource://normandy/actions/BaseAction.jsm");
+ChromeUtils.defineModuleGetter(this, "Services", "resource://gre/modules/Services.jsm");
+ChromeUtils.defineModuleGetter(this, "IndexedDB", "resource://gre/modules/IndexedDB.jsm");
+ChromeUtils.defineModuleGetter(this, "TelemetryEnvironment", "resource://gre/modules/TelemetryEnvironment.jsm");
+ChromeUtils.defineModuleGetter(this, "PrefUtils", "resource://normandy/lib/PrefUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "TelemetryEvents", "resource://normandy/lib/TelemetryEvents.jsm");
+
+/**
+ * PreferenceRollouts store info about an active or expired preference rollout.
+ * @typedef {object} PreferenceRollout
+ * @property {string} slug
+ * Unique slug of the experiment
+ * @property {string} state
+ * The current state of the rollout: "active", "rolled-back", "graduated".
+ * Active means that Normandy is actively managing therollout. Rolled-back
+ * means that the rollout was previously active, but has been rolled back for
+ * this user. Graduated means that the built-in default now matches the
+ * rollout value, and so Normandy is no longer managing the preference.
+ * @property {Array<PreferenceSpec>} preferences
+ * An array of preferences specifications involved in the rollout.
+ */
+
+ /**
+ * PreferenceSpec describe how a preference should change during a rollout.
+ * @typedef {object} PreferenceSpec
+ * @property {string} preferenceName
+ * The preference to modify.
+ * @property {string} preferenceType
+ * Type of the preference being set.
+ * @property {string|integer|boolean} value
+ * The value to change the preference to.
+ * @property {string|integer|boolean} previousValue
+ * The value the preference would have on the default branch if this rollout
+ * were not active.
+ */
+
+var EXPORTED_SYMBOLS = ["PreferenceRollouts"];
+const STARTUP_PREFS_BRANCH = "app.normandy.startupRolloutPrefs.";
+const DB_NAME = "normandy-preference-rollout";
+const STORE_NAME = "preference-rollouts";
+const DB_OPTIONS = {version: 1};
+
+/**
+ * Create a new connection to the database.
+ */
+function openDatabase() {
+ return IndexedDB.open(DB_NAME, DB_OPTIONS, db => {
+ db.createObjectStore(STORE_NAME, {
+ keyPath: "slug",
+ });
+ });
+}
+
+/**
+ * Cache the database connection so that it is shared among multiple operations.
+ */
+let databasePromise;
+function getDatabase() {
+ if (!databasePromise) {
+ databasePromise = openDatabase();
+ }
+ return databasePromise;
+}
+
+/**
+ * Get a transaction for interacting with the rollout store.
+ *
+ * NOTE: Methods on the store returned by this function MUST be called
+ * synchronously, otherwise the transaction with the store will expire.
+ * This is why the helper takes a database as an argument; if we fetched the
+ * database in the helper directly, the helper would be async and the
+ * transaction would expire before methods on the store were called.
+ */
+function getStore(db) {
+ return db.objectStore(STORE_NAME, "readwrite");
+}
+
+var PreferenceRollouts = {
+ STATE_ACTIVE: "active",
+ STATE_ROLLED_BACK: "rolled-back",
+ STATE_GRADUATED: "graduated",
+
+ /**
+ * Update the rollout database with changes that happened during early startup.
+ * @param {object} rolloutPrefsChanged Map from pref name to previous pref value
+ */
+ async recordOriginalValues(originalPreferences) {
+ for (const rollout of await this.getAllActive()) {
+ let changed = false;
+
+ // Count the number of preferences in this rollout that are now redundant.
+ let prefMatchingDefaultCount = 0;
+
+ for (const prefSpec of rollout.preferences) {
+ const builtInDefault = originalPreferences[prefSpec.preferenceName];
+ if (prefSpec.value === builtInDefault) {
+ prefMatchingDefaultCount++;
+ }
+ // Store the current built-in default. That way, if the preference is
+ // rolled back during the current session (ie, until the browser is
+ // shut down), the correct value will be used.
+ if (prefSpec.previousValue !== builtInDefault) {
+ prefSpec.previousValue = builtInDefault;
+ changed = true;
+ }
+ }
+
+ if (prefMatchingDefaultCount === rollout.preferences.length) {
+ // Firefox's builtin defaults have caught up to the rollout, making all
+ // of the rollout's changes redundant, so graduate the rollout.
+ rollout.state = this.STATE_GRADUATED;
+ changed = true;
+ TelemetryEvents.sendEvent("graduate", "preference_rollout", rollout.slug, {});
+ }
+
+ if (changed) {
+ const db = await getDatabase();
+ await getStore(db).put(rollout);
+ }
+ }
+ },
+
+ async init() {
+ for (const rollout of await this.getAllActive()) {
+ TelemetryEnvironment.setExperimentActive(rollout.slug, rollout.state, {type: "normandy-prefrollout"});
+ }
+ },
+
+ async uninit() {
+ await this.saveStartupPrefs();
+ },
+
+ /**
+ * Test wrapper that temporarily replaces the stored rollout data with fake
+ * data for testing.
+ */
+ withTestMock(testFunction) {
+ return async function inner(...args) {
+ let db = await getDatabase();
+ const oldData = await getStore(db).getAll();
+ await getStore(db).clear();
+ try {
+ await testFunction(...args);
+ } finally {
+ db = await getDatabase();
+ const store = getStore(db);
+ let promises = [store.clear()];
+ for (const d of oldData) {
+ promises.push(store.add(d));
+ }
+ await Promise.all(promises);
+ }
+ };
+ },
+
+ /**
+ * Add a new rollout
+ * @param {PreferenceRollout} rollout
+ */
+ async add(rollout) {
+ const db = await getDatabase();
+ return getStore(db).add(rollout);
+ },
+
+ /**
+ * Update an existing rollout
+ * @param {PreferenceRollout} rollout
+ * @throws If a matching rollout does not exist.
+ */
+ async update(rollout) {
+ const db = await getDatabase();
+ if (!await this.has(rollout.slug)) {
+ throw new Error(`Tried to update ${rollout.slug}, but it doesn't already exist.`);
+ }
+ return getStore(db).put(rollout);
+ },
+
+ /**
+ * Test whether there is a rollout in storage with the given slug.
+ * @param {string} slug
+ * @returns {boolean}
+ */
+ async has(slug) {
+ const db = await getDatabase();
+ const rollout = await getStore(db).get(slug);
+ return !!rollout;
+ },
+
+ /**
+ * Get a rollout by slug
+ * @param {string} slug
+ */
+ async get(slug) {
+ const db = await getDatabase();
+ return getStore(db).get(slug);
+ },
+
+ /** Get all rollouts in the database. */
+ async getAll() {
+ const db = await getDatabase();
+ return getStore(db).getAll();
+ },
+
+ /** Get all rollouts in the "active" state. */
+ async getAllActive() {
+ const rollouts = await this.getAll();
+ return rollouts.filter(rollout => rollout.state === this.STATE_ACTIVE);
+ },
+
+ /**
+ * Save in-progress preference rollouts in a sub-branch of the normandy prefs.
+ * On startup, we read these to set the rollout values.
+ */
+ async saveStartupPrefs() {
+ const prefBranch = Services.prefs.getBranch(STARTUP_PREFS_BRANCH);
+ prefBranch.deleteBranch("");
+
+ for (const rollout of await this.getAllActive()) {
+ for (const prefSpec of rollout.preferences) {
+ PrefUtils.setPref("user", STARTUP_PREFS_BRANCH + prefSpec.preferenceName, prefSpec.value);
+ }
+ }
+ },
+
+ /**
+ * Close the current database connection if it is open. If it is not open,
+ * this is a no-op.
+ */
+ async closeDB() {
+ if (databasePromise) {
+ const promise = databasePromise;
+ databasePromise = null;
+ const db = await promise;
+ await db.close();
+ }
+ },
+};
--- a/toolkit/components/normandy/lib/RecipeRunner.jsm
+++ b/toolkit/components/normandy/lib/RecipeRunner.jsm
@@ -201,16 +201,21 @@ var RecipeRunner = {
// gracefully fail without this info if they need it.
}
}
// Fetch recipes before execution in case we fail and exit early.
let recipes;
try {
recipes = await NormandyApi.fetchRecipes({enabled: true});
+ log.debug(
+ `Fetched ${recipes.length} recipes from the server: ` +
+ recipes.map(r => r.name).join(", ")
+ );
+
} catch (e) {
const apiUrl = Services.prefs.getCharPref(API_URL_PREF);
log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
let status = Uptake.RUNNER_SERVER_ERROR;
if (/NetworkError/.test(e)) {
status = Uptake.RUNNER_NETWORK_ERROR;
} else if (e instanceof NormandyApi.InvalidSignatureError) {
--- a/toolkit/components/normandy/lib/TelemetryEvents.jsm
+++ b/toolkit/components/normandy/lib/TelemetryEvents.jsm
@@ -10,33 +10,47 @@ var EXPORTED_SYMBOLS = ["TelemetryEvents
const TELEMETRY_CATEGORY = "normandy";
const TelemetryEvents = {
init() {
Services.telemetry.registerEvents(TELEMETRY_CATEGORY, {
enroll: {
methods: ["enroll"],
- objects: ["preference_study", "addon_study"],
+ objects: ["preference_study", "addon_study", "preference_rollout"],
extra_keys: ["experimentType", "branch", "addonId", "addonVersion"],
record_on_release: true,
},
enroll_failure: {
methods: ["enrollFailed"],
- objects: ["addon_study"],
- extra_keys: ["reason"],
+ objects: ["addon_study", "preference_rollout"],
+ extra_keys: ["reason", "preference"],
+ record_on_release: true,
+ },
+
+ update: {
+ methods: ["update"],
+ objects: ["preference_rollout"],
+ extra_keys: ["previousState"],
record_on_release: true,
},
unenroll: {
methods: ["unenroll"],
objects: ["preference_study", "addon_study"],
extra_keys: ["reason", "didResetValue", "addonId", "addonVersion"],
record_on_release: true,
},
+
+ graduated: {
+ methods: ["graduated"],
+ objects: ["preference_rollout"],
+ extra_keys: [],
+ record_on_release: true,
+ },
});
},
sendEvent(method, object, value, extra) {
Services.telemetry.recordEvent(TELEMETRY_CATEGORY, method, object, value, extra);
},
};
--- a/toolkit/components/normandy/test/browser/browser.ini
+++ b/toolkit/components/normandy/test/browser/browser.ini
@@ -3,26 +3,28 @@ support-files =
action_server.sjs
fixtures/normandy.xpi
head = head.js
[browser_about_preferences.js]
# Skip this test when FHR/Telemetry aren't available.
skip-if = !healthreport || !telemetry
[browser_about_studies.js]
skip-if = true # bug 1442712
-[browser_action_ConsoleLog.js]
+[browser_actions_ConsoleLogAction.js]
+[browser_actions_PreferenceRolloutAction.js]
[browser_ActionSandboxManager.js]
[browser_ActionsManager.js]
[browser_Addons.js]
[browser_AddonStudies.js]
[browser_BaseAction.js]
[browser_CleanupManager.js]
[browser_ClientEnvironment.js]
[browser_EventEmitter.js]
[browser_FilterExpressions.js]
[browser_Heartbeat.js]
[browser_LogManager.js]
[browser_Normandy.js]
[browser_NormandyDriver.js]
[browser_PreferenceExperiments.js]
+[browser_PreferenceRollouts.js]
[browser_RecipeRunner.js]
[browser_ShieldPreferences.js]
-[browser_Storage.js]
\ No newline at end of file
+[browser_Storage.js]
--- a/toolkit/components/normandy/test/browser/browser_Normandy.js
+++ b/toolkit/components/normandy/test/browser/browser_Normandy.js
@@ -1,52 +1,54 @@
"use strict";
ChromeUtils.import("resource://normandy/Normandy.jsm", this);
ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
ChromeUtils.import("resource://normandy/lib/PreferenceExperiments.jsm", this);
+ChromeUtils.import("resource://normandy/lib/PreferenceRollouts.jsm", this);
ChromeUtils.import("resource://normandy/lib/RecipeRunner.jsm", this);
ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
ChromeUtils.import("resource://normandy-content/AboutPages.jsm", this);
const experimentPref1 = "test.initExperimentPrefs1";
const experimentPref2 = "test.initExperimentPrefs2";
const experimentPref3 = "test.initExperimentPrefs3";
const experimentPref4 = "test.initExperimentPrefs4";
function withStubInits(testFunction) {
return decorate(
withStub(AboutPages, "init"),
withStub(AddonStudies, "init"),
+ withStub(PreferenceRollouts, "init"),
withStub(PreferenceExperiments, "init"),
withStub(RecipeRunner, "init"),
withStub(TelemetryEvents, "init"),
- testFunction
+ () => testFunction(),
);
}
decorate_task(
withPrefEnv({
set: [
[`app.normandy.startupExperimentPrefs.${experimentPref1}`, true],
[`app.normandy.startupExperimentPrefs.${experimentPref2}`, 2],
[`app.normandy.startupExperimentPrefs.${experimentPref3}`, "string"],
],
}),
- async function testInitExperimentPrefs() {
+ async function testApplyStartupPrefs() {
const defaultBranch = Services.prefs.getDefaultBranch("");
for (const pref of [experimentPref1, experimentPref2, experimentPref3]) {
is(
defaultBranch.getPrefType(pref),
defaultBranch.PREF_INVALID,
`Pref ${pref} don't exist before being initialized.`,
);
}
- Normandy.initExperimentPrefs();
+ Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs.");
ok(
defaultBranch.getBoolPref(experimentPref1),
`Pref ${experimentPref1} has a default value after being initialized.`,
);
is(
defaultBranch.getIntPref(experimentPref2),
2,
@@ -70,42 +72,42 @@ decorate_task(
);
decorate_task(
withPrefEnv({
set: [
["app.normandy.startupExperimentPrefs.test.existingPref", "experiment"],
],
}),
- async function testInitExperimentPrefsExisting() {
+ async function testApplyStartupPrefsExisting() {
const defaultBranch = Services.prefs.getDefaultBranch("");
defaultBranch.setCharPref("test.existingPref", "default");
- Normandy.initExperimentPrefs();
+ Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs.");
is(
defaultBranch.getCharPref("test.existingPref"),
"experiment",
- "initExperimentPrefs overwrites the default values of existing preferences.",
+ "applyStartupPrefs overwrites the default values of existing preferences.",
);
},
);
decorate_task(
withPrefEnv({
set: [
["app.normandy.startupExperimentPrefs.test.mismatchPref", "experiment"],
],
}),
- async function testInitExperimentPrefsMismatch() {
+ async function testApplyStartupPrefsMismatch() {
const defaultBranch = Services.prefs.getDefaultBranch("");
defaultBranch.setIntPref("test.mismatchPref", 2);
- Normandy.initExperimentPrefs();
+ Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs.");
is(
defaultBranch.getPrefType("test.mismatchPref"),
Services.prefs.PREF_INT,
- "initExperimentPrefs skips prefs that don't match the existing default value's type.",
+ "applyStartupPrefs skips prefs that don't match the existing default value's type.",
);
},
);
decorate_task(
withStub(Normandy, "finishInit"),
async function testStartupDelayed(finishInitStub) {
Normandy.init();
@@ -120,66 +122,56 @@ decorate_task(
"Once the sessionstore-windows-restored event is observed, finishInit should be called.",
);
},
);
// During startup, preferences that are changed for experiments should
// be record by calling PreferenceExperiments.recordOriginalValues.
decorate_task(
- withPrefEnv({
- set: [
- [`app.normandy.startupExperimentPrefs.${experimentPref1}`, true],
- [`app.normandy.startupExperimentPrefs.${experimentPref2}`, 2],
- [`app.normandy.startupExperimentPrefs.${experimentPref3}`, "string"],
- [`app.normandy.startupExperimentPrefs.${experimentPref4}`, "another string"],
- ],
- }),
withStub(PreferenceExperiments, "recordOriginalValues"),
- async function testInitExperimentPrefs(recordOriginalValuesStub) {
+ withStub(PreferenceRollouts, "recordOriginalValues"),
+ async function testApplyStartupPrefs(experimentsRecordOriginalValuesStub, rolloutsRecordOriginalValueStub) {
const defaultBranch = Services.prefs.getDefaultBranch("");
defaultBranch.setBoolPref(experimentPref1, false);
defaultBranch.setIntPref(experimentPref2, 1);
defaultBranch.setCharPref(experimentPref3, "original string");
// experimentPref4 is left unset
- Normandy.initExperimentPrefs();
+ Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs.");
+ Normandy.studyPrefsChanged = {"test.study-pref": 1};
+ Normandy.rolloutPrefsChanged = {"test.rollout-pref": 1};
await Normandy.finishInit();
Assert.deepEqual(
- recordOriginalValuesStub.getCall(0).args,
- [{
- [experimentPref1]: false,
- [experimentPref2]: 1,
- [experimentPref3]: "original string",
- [experimentPref4]: null, // null because it was not initially set.
- }],
- "finishInit should record original values of the prefs initExperimentPrefs changed",
+ experimentsRecordOriginalValuesStub.args,
+ [[{"test.study-pref": 1}]],
+ "finishInit should record original values of the study prefs",
);
-
- for (const pref of [experimentPref1, experimentPref2, experimentPref3, experimentPref4]) {
- Services.prefs.clearUserPref(pref);
- defaultBranch.deleteBranch(pref);
- }
+ Assert.deepEqual(
+ rolloutsRecordOriginalValueStub.args,
+ [[{"test.rollout-pref": 1}]],
+ "finishInit should record original values of the study prefs",
+ );
},
);
// Test that startup prefs are handled correctly when there is a value on the user branch but not the default branch.
decorate_task(
withPrefEnv({
set: [
["app.normandy.startupExperimentPrefs.testing.does-not-exist", "foo"],
["testing.does-not-exist", "foo"],
],
}),
withStub(PreferenceExperiments, "recordOriginalValues"),
- async function testInitExperimentPrefsNoDefaultValue() {
- Normandy.initExperimentPrefs();
- ok(true, "initExperimentPrefs should not throw for non-existant prefs");
+ async function testApplyStartupPrefsNoDefaultValue() {
+ Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs");
+ ok(true, "initExperimentPrefs should not throw for prefs that doesn't exist on the default branch");
},
);
decorate_task(
withStubInits,
async function testStartup() {
const initObserved = TestUtils.topicObserved("shield-init-complete");
await Normandy.finishInit();
@@ -189,66 +181,85 @@ decorate_task(
ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
await initObserved;
}
);
decorate_task(
withStubInits,
async function testStartupPrefInitFail() {
- PreferenceExperiments.init.returns(Promise.reject(new Error("oh no")));
+ PreferenceExperiments.init.rejects();
await Normandy.finishInit();
ok(AboutPages.init.called, "startup calls AboutPages.init");
ok(AddonStudies.init.called, "startup calls AddonStudies.init");
ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
+ ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
}
);
decorate_task(
withStubInits,
async function testStartupAboutPagesInitFail() {
- AboutPages.init.returns(Promise.reject(new Error("oh no")));
+ AboutPages.init.rejects();
await Normandy.finishInit();
ok(AboutPages.init.called, "startup calls AboutPages.init");
ok(AddonStudies.init.called, "startup calls AddonStudies.init");
ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
+ ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
}
);
decorate_task(
withStubInits,
async function testStartupAddonStudiesInitFail() {
- AddonStudies.init.returns(Promise.reject(new Error("oh no")));
+ AddonStudies.init.rejects();
await Normandy.finishInit();
ok(AboutPages.init.called, "startup calls AboutPages.init");
ok(AddonStudies.init.called, "startup calls AddonStudies.init");
ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
+ ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
}
);
decorate_task(
withStubInits,
async function testStartupTelemetryEventsInitFail() {
TelemetryEvents.init.throws();
await Normandy.finishInit();
ok(AboutPages.init.called, "startup calls AboutPages.init");
ok(AddonStudies.init.called, "startup calls AddonStudies.init");
ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
+ ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
+ }
+);
+
+decorate_task(
+ withStubInits,
+ async function testStartupPreferenceRolloutsInitFail() {
+ PreferenceRollouts.init.throws();
+
+ await Normandy.finishInit();
+ ok(AboutPages.init.called, "startup calls AboutPages.init");
+ ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+ ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
+ ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
+ ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
+ ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
}
);
decorate_task(
withMockPreferences,
async function testPrefMigration(mockPreferences) {
const legacyPref = "extensions.shield-recipe-client.test";
const migratedPref = "app.normandy.test";
--- a/toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js
+++ b/toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js
@@ -713,17 +713,17 @@ decorate_task(
});
Assert.deepEqual(
setActiveStub.getCall(0).args,
["test", "branch", {type: "normandy-exp"}],
"Experiment is registered by start()",
);
await PreferenceExperiments.stop("test", {reason: "test-reason"});
- ok(setInactiveStub.calledWith("test", "branch"), "Experiment is unregistered by stop()");
+ Assert.deepEqual(setInactiveStub.args, [["test"]], "Experiment is unregistered by stop()");
Assert.deepEqual(
sendEventStub.getCall(0).args,
["enroll", "preference_study", "test", {
experimentType: "exp",
branch: "branch",
}],
"PreferenceExperiments.start() should send the correct telemetry event"
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/browser_PreferenceRollouts.js
@@ -0,0 +1,220 @@
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/IndexedDB.jsm", this);
+ChromeUtils.import("resource://gre/modules/TelemetryEnvironment.jsm", this);
+ChromeUtils.import("resource://normandy/lib/PreferenceRollouts.jsm", this);
+ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
+
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ async function testGetMissing() {
+ is(
+ await PreferenceRollouts.get("does-not-exist"),
+ null,
+ "get should return null when the requested rollout does not exist"
+ );
+ }
+);
+
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ async function testAddUpdateAndGet() {
+ const rollout = {slug: "test-rollout", state: PreferenceRollouts.STATE_ACTIVE, preferences: []};
+ await PreferenceRollouts.add(rollout);
+ let storedRollout = await PreferenceRollouts.get(rollout.slug);
+ Assert.deepEqual(rollout, storedRollout, "get should retrieve a rollout from storage.");
+
+ rollout.state = PreferenceRollouts.STATE_GRADUATED;
+ await PreferenceRollouts.update(rollout);
+ storedRollout = await PreferenceRollouts.get(rollout.slug);
+ Assert.deepEqual(rollout, storedRollout, "get should retrieve a rollout from storage.");
+ },
+);
+
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ async function testCantUpdateNonexistent() {
+ const rollout = {slug: "test-rollout", state: PreferenceRollouts.STATE_ACTIVE, preferences: []};
+ await Assert.rejects(
+ PreferenceRollouts.update(rollout),
+ /doesn't already exist/,
+ "Update should fail if the rollout doesn't exist",
+ );
+ ok(!await PreferenceRollouts.has("test-rollout"), "rollout should not have been added");
+ },
+);
+
+
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ async function testGetAll() {
+ const rollout1 = {slug: "test-rollout-1", preference: []};
+ const rollout2 = {slug: "test-rollout-2", preference: []};
+ await PreferenceRollouts.add(rollout1);
+ await PreferenceRollouts.add(rollout2);
+
+ const storedRollouts = await PreferenceRollouts.getAll();
+ Assert.deepEqual(
+ storedRollouts.sort((a, b) => a.id - b.id),
+ [rollout1, rollout2],
+ "getAll should return every stored rollout.",
+ );
+ }
+);
+
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ async function testGetAllActive() {
+ const rollout1 = {slug: "test-rollout-1", state: PreferenceRollouts.STATE_ACTIVE};
+ const rollout2 = {slug: "test-rollout-2", state: PreferenceRollouts.STATE_GRADUATED};
+ const rollout3 = {slug: "test-rollout-3", state: PreferenceRollouts.STATE_ROLLED_BACK};
+ await PreferenceRollouts.add(rollout1);
+ await PreferenceRollouts.add(rollout2);
+ await PreferenceRollouts.add(rollout3);
+
+ const activeRollouts = await PreferenceRollouts.getAllActive();
+ Assert.deepEqual(activeRollouts, [rollout1], "getAllActive should return only active rollouts");
+ }
+);
+
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ async function testHas() {
+ const rollout = {slug: "test-rollout", preferences: []};
+ await PreferenceRollouts.add(rollout);
+ ok(await PreferenceRollouts.has(rollout.slug), "has should return true for an existing rollout");
+ ok(!await PreferenceRollouts.has("does not exist"), "has should return false for a missing rollout");
+ }
+);
+
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ async function testCloseDatabase() {
+ await PreferenceRollouts.closeDB();
+ const openSpy = sinon.spy(IndexedDB, "open");
+ sinon.assert.notCalled(openSpy);
+
+ try {
+ // Using rollouts at all should open the database, but only once.
+ await PreferenceRollouts.has("foo");
+ await PreferenceRollouts.get("foo");
+ sinon.assert.calledOnce(openSpy);
+ openSpy.reset();
+
+ // close can be called multiple times
+ await PreferenceRollouts.closeDB();
+ await PreferenceRollouts.closeDB();
+ // and don't cause the database to be opened (that would be weird)
+ sinon.assert.notCalled(openSpy);
+
+ // After being closed, new operations cause the database to be opened again, but only once
+ await PreferenceRollouts.has("foo");
+ await PreferenceRollouts.get("foo");
+ sinon.assert.calledOnce(openSpy);
+
+ } finally {
+ openSpy.restore();
+ }
+ }
+);
+
+// recordOriginalValue should update storage to note the original values
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ async function testRecordOriginalValuesUpdatesPreviousValues() {
+ await PreferenceRollouts.add({
+ slug: "test-rollout",
+ state: PreferenceRollouts.STATE_ACTIVE,
+ preferences: [{preferenceName: "test.pref", value: 2, previousValue: null}],
+ });
+
+ await PreferenceRollouts.recordOriginalValues({"test.pref": 1});
+
+ Assert.deepEqual(
+ await PreferenceRollouts.getAll(),
+ [{
+ slug: "test-rollout",
+ state: PreferenceRollouts.STATE_ACTIVE,
+ preferences: [{preferenceName: "test.pref", value: 2, previousValue: 1}],
+ }],
+ "rollout in database should be updated",
+ );
+ },
+);
+
+// recordOriginalValue should graduate a study when it is no longer relevant.
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ withStub(TelemetryEvents, "sendEvent"),
+ async function testRecordOriginalValuesUpdatesPreviousValues(sendEventStub) {
+ await PreferenceRollouts.add({
+ slug: "test-rollout",
+ state: PreferenceRollouts.STATE_ACTIVE,
+ preferences: [
+ {preferenceName: "test.pref1", value: 2, previousValue: null},
+ {preferenceName: "test.pref2", value: 2, previousValue: null},
+ ],
+ });
+
+ // one pref being the same isn't enough to graduate
+ await PreferenceRollouts.recordOriginalValues({"test.pref1": 1, "test.pref2": 2});
+ let rollout = await PreferenceRollouts.get("test-rollout");
+ is(
+ rollout.state,
+ PreferenceRollouts.STATE_ACTIVE,
+ "rollouts should remain active when only one pref matches the built-in default",
+ );
+
+ Assert.deepEqual(sendEventStub.args, [], "no events should be sent yet");
+
+ // both prefs is enough
+ await PreferenceRollouts.recordOriginalValues({"test.pref1": 2, "test.pref2": 2});
+ rollout = await PreferenceRollouts.get("test-rollout");
+ is(
+ rollout.state,
+ PreferenceRollouts.STATE_GRADUATED,
+ "rollouts should graduate when all prefs matches the built-in defaults",
+ );
+
+ Assert.deepEqual(
+ sendEventStub.args,
+ [["graduate", "preference_rollout", "test-rollout", {}]],
+ "a graduation event should be sent",
+ );
+ },
+);
+
+// init should mark active rollouts in telemetry
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ withStub(TelemetryEnvironment, "setExperimentActive"),
+ async function testInitTelemetry(setExperimentActiveStub) {
+ await PreferenceRollouts.add({
+ slug: "test-rollout-active-1",
+ state: PreferenceRollouts.STATE_ACTIVE,
+ });
+ await PreferenceRollouts.add({
+ slug: "test-rollout-active-2",
+ state: PreferenceRollouts.STATE_ACTIVE,
+ });
+ await PreferenceRollouts.add({
+ slug: "test-rollout-rolled-back",
+ state: PreferenceRollouts.STATE_ROLLED_BACK,
+ });
+ await PreferenceRollouts.add({
+ slug: "test-rollout-graduated",
+ state: PreferenceRollouts.STATE_GRADUATED,
+ });
+
+ await PreferenceRollouts.init();
+
+ Assert.deepEqual(
+ setExperimentActiveStub.args,
+ [
+ ["test-rollout-active-1", "active", {type: "normandy-prefrollout"}],
+ ["test-rollout-active-2", "active", {type: "normandy-prefrollout"}],
+ ],
+ "init should set activate a telemetry experiment for active preferences"
+ );
+ },
+);
rename from toolkit/components/normandy/test/browser/browser_action_ConsoleLog.js
rename to toolkit/components/normandy/test/browser/browser_actions_ConsoleLogAction.js
--- a/toolkit/components/normandy/test/browser/browser_action_ConsoleLog.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_ConsoleLogAction.js
@@ -1,32 +1,32 @@
"use strict";
-ChromeUtils.import("resource://normandy/actions/ConsoleLog.jsm", this);
+ChromeUtils.import("resource://normandy/actions/ConsoleLogAction.jsm", this);
ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
// Test that logging works
add_task(async function logging_works() {
- const action = new ConsoleLog();
+ const action = new ConsoleLogAction();
const infoStub = sinon.stub(action.log, "info");
try {
const recipe = {id: 1, arguments: {message: "Hello, world!"}};
await action.runRecipe(recipe);
Assert.deepEqual(infoStub.args, ["Hello, world!"], "the message should be logged");
} finally {
infoStub.restore();
}
});
// test that argument validation works
decorate_task(
withStub(Uptake, "reportRecipe"),
async function arguments_are_validated(reportRecipeStub) {
- const action = new ConsoleLog();
+ const action = new ConsoleLogAction();
const infoStub = sinon.stub(action.log, "info");
try {
// message is required
let recipe = {id: 1, arguments: {}};
await action.runRecipe(recipe);
Assert.deepEqual(infoStub.args, [], "no message should be logged");
Assert.deepEqual(reportRecipeStub.args, [[recipe.id, Uptake.RECIPE_EXECUTION_ERROR]]);
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/browser_actions_PreferenceRolloutAction.js
@@ -0,0 +1,359 @@
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/Services.jsm", this);
+ChromeUtils.import("resource://gre/modules/Preferences.jsm", this);
+ChromeUtils.import("resource://gre/modules/TelemetryEnvironment.jsm", this);
+ChromeUtils.import("resource://normandy/actions/PreferenceRolloutAction.jsm", this);
+ChromeUtils.import("resource://normandy/lib/PreferenceRollouts.jsm", this);
+ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
+
+// Test that a simple recipe enrolls as expected
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ withStub(TelemetryEnvironment, "setExperimentActive"),
+ withStub(TelemetryEvents, "sendEvent"),
+ async function simple_recipe_enrollment(setExperimentActiveStub, sendEventStub) {
+ const recipe = {
+ id: 1,
+ arguments: {
+ slug: "test-rollout",
+ preferences: [
+ {preferenceName: "test.pref1", value: 1},
+ {preferenceName: "test.pref2", value: true},
+ {preferenceName: "test.pref3", value: "it works"},
+ ],
+ },
+ };
+
+ const action = new PreferenceRolloutAction();
+ await action.runRecipe(recipe);
+ await action.finalize();
+
+ // rollout prefs are set
+ is(Services.prefs.getIntPref("test.pref1"), 1, "integer pref should be set");
+ is(Services.prefs.getBoolPref("test.pref2"), true, "boolean pref should be set");
+ is(Services.prefs.getCharPref("test.pref3"), "it works", "string pref should be set");
+
+ // start up prefs are set
+ is(Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref1"), 1, "integer startup pref should be set");
+ is(Services.prefs.getBoolPref("app.normandy.startupRolloutPrefs.test.pref2"), true, "boolean startup pref should be set");
+ is(Services.prefs.getCharPref("app.normandy.startupRolloutPrefs.test.pref3"), "it works", "string startup pref should be set");
+
+ // rollout was stored
+ Assert.deepEqual(
+ await PreferenceRollouts.getAll(),
+ [{
+ slug: "test-rollout",
+ state: PreferenceRollouts.STATE_ACTIVE,
+ preferences: [
+ {preferenceName: "test.pref1", value: 1, previousValue: null},
+ {preferenceName: "test.pref2", value: true, previousValue: null},
+ {preferenceName: "test.pref3", value: "it works", previousValue: null},
+ ],
+ }],
+ "Rollout should be stored in db"
+ );
+
+ Assert.deepEqual(
+ sendEventStub.args,
+ [["enroll", "preference_rollout", recipe.arguments.slug, {}]],
+ "an enrollment event should be sent"
+ );
+ Assert.deepEqual(
+ setExperimentActiveStub.args,
+ [["test-rollout", "active", {type: "normandy-prefrollout"}]],
+ "a telemetry experiment should be activated",
+ );
+
+ // Cleanup
+ Services.prefs.getDefaultBranch("").deleteBranch("test.pref1");
+ Services.prefs.getDefaultBranch("").deleteBranch("test.pref2");
+ Services.prefs.getDefaultBranch("").deleteBranch("test.pref3");
+ },
+);
+
+// Test that an enrollment's values can change, be removed, and be added
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ withStub(TelemetryEvents, "sendEvent"),
+ async function update_enrollment(sendEventStub) {
+ // first enrollment
+ const recipe = {
+ id: 1,
+ arguments: {
+ slug: "test-rollout",
+ preferences: [
+ {preferenceName: "test.pref1", value: 1},
+ {preferenceName: "test.pref2", value: 1},
+ ],
+ },
+ };
+
+ let action = new PreferenceRolloutAction();
+ await action.runRecipe(recipe);
+ await action.finalize();
+
+ const defaultBranch = Services.prefs.getDefaultBranch("");
+ is(defaultBranch.getIntPref("test.pref1"), 1, "pref1 should be set");
+ is(defaultBranch.getIntPref("test.pref2"), 1, "pref2 should be set");
+ is(Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref1"), 1, "startup pref1 should be set");
+ is(Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref2"), 1, "startup pref2 should be set");
+
+ // update existing enrollment
+ recipe.arguments.preferences = [
+ // pref1 is removed
+ // pref2's value is updated
+ {preferenceName: "test.pref2", value: 2},
+ // pref3 is added
+ {preferenceName: "test.pref3", value: 2},
+ ];
+ action = new PreferenceRolloutAction();
+ await action.runRecipe(recipe);
+ await action.finalize();
+
+ is(Services.prefs.getPrefType("test.pref1"), Services.prefs.PREF_INVALID, "pref1 should be removed");
+ is(Services.prefs.getIntPref("test.pref2"), 2, "pref2 should be updated");
+ is(Services.prefs.getIntPref("test.pref3"), 2, "pref3 should be added");
+
+ is(Services.prefs.getPrefType(
+ "app.normandy.startupRolloutPrefs.test.pref1"),
+ Services.prefs.PREF_INVALID,
+ "startup pref1 should be removed",
+ );
+ is(
+ Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref2"),
+ 2,
+ "startup pref2 should be updated",
+ );
+ is(
+ Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref3"),
+ 2,
+ "startup pref3 should be added",
+ );
+
+ // rollout in the DB has been updated
+ Assert.deepEqual(
+ await PreferenceRollouts.getAll(),
+ [{
+ slug: "test-rollout",
+ state: PreferenceRollouts.STATE_ACTIVE,
+ preferences: [
+ {preferenceName: "test.pref2", value: 2, previousValue: null},
+ {preferenceName: "test.pref3", value: 2, previousValue: null},
+ ],
+ }],
+ "Rollout should be updated in db"
+ );
+
+ Assert.deepEqual(
+ sendEventStub.args,
+ [
+ ["enroll", "preference_rollout", "test-rollout", {}],
+ ["update", "preference_rollout", "test-rollout", {previousState: "active"}],
+ ],
+ "update event was sent"
+ );
+
+ // Cleanup
+ Services.prefs.getDefaultBranch("").deleteBranch("test.pref1");
+ Services.prefs.getDefaultBranch("").deleteBranch("test.pref2");
+ Services.prefs.getDefaultBranch("").deleteBranch("test.pref3");
+ },
+);
+
+// Test that a graduated rollout can be ungraduated
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ withStub(TelemetryEvents, "sendEvent"),
+ async function ungraduate_enrollment(sendEventStub) {
+ Services.prefs.getDefaultBranch("").setIntPref("test.pref", 1);
+ await PreferenceRollouts.add({
+ slug: "test-rollout",
+ state: PreferenceRollouts.STATE_GRADUATED,
+ preferences: [{preferenceName: "test.pref", value: 1, previousValue: 1}],
+ });
+
+ let recipe = {
+ id: 1,
+ arguments: {
+ slug: "test-rollout",
+ preferences: [{preferenceName: "test.pref", value: 2}],
+ },
+ };
+
+ const action = new PreferenceRolloutAction();
+ await action.runRecipe(recipe);
+ await action.finalize();
+
+ is(Services.prefs.getIntPref("test.pref"), 2, "pref should be updated");
+ is(Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref"), 2, "startup pref should be set");
+
+ // rollout in the DB has been ungraduated
+ Assert.deepEqual(
+ await PreferenceRollouts.getAll(),
+ [{
+ slug: "test-rollout",
+ state: PreferenceRollouts.STATE_ACTIVE,
+ preferences: [{preferenceName: "test.pref", value: 2, previousValue: 1}],
+ }],
+ "Rollout should be updated in db"
+ );
+
+ Assert.deepEqual(
+ sendEventStub.args,
+ [
+ ["update", "preference_rollout", "test-rollout", {previousState: "graduated"}],
+ ],
+ "correct events was sent"
+ );
+
+ // Cleanup
+ Services.prefs.getDefaultBranch("").deleteBranch("test.pref");
+ },
+);
+
+// Test when recipes conflict, only one is applied
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ withStub(TelemetryEvents, "sendEvent"),
+ async function conflicting_recipes(sendEventStub) {
+ // create two recipes that each share a pref and have a unique pref.
+ const recipe1 = {
+ id: 1,
+ arguments: {
+ slug: "test-rollout-1",
+ preferences: [
+ {preferenceName: "test.pref1", value: 1},
+ {preferenceName: "test.pref2", value: 1},
+ ],
+ },
+ };
+ const recipe2 = {
+ id: 2,
+ arguments: {
+ slug: "test-rollout-2",
+ preferences: [
+ {preferenceName: "test.pref1", value: 2},
+ {preferenceName: "test.pref3", value: 2},
+ ],
+ },
+ };
+
+ // running both in the same session
+ let action = new PreferenceRolloutAction();
+ await action.runRecipe(recipe1);
+ await action.runRecipe(recipe2);
+ await action.finalize();
+
+ // running recipe2 in a separate session shouldn't change things
+ action = new PreferenceRolloutAction();
+ await action.runRecipe(recipe2);
+ await action.finalize();
+
+ is(Services.prefs.getIntPref("test.pref1"), 1, "pref1 is set to recipe1's value");
+ is(Services.prefs.getIntPref("test.pref2"), 1, "pref2 is set to recipe1's value");
+ is(Services.prefs.getPrefType("test.pref3"), Services.prefs.PREF_INVALID, "pref3 is not set");
+
+ is(Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref1"), 1, "startup pref1 is set to recipe1's value");
+ is(Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref2"), 1, "startup pref2 is set to recipe1's value");
+ is(Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref3"), Services.prefs.PREF_INVALID, "startup pref3 is not set");
+
+ // only successful rollout was stored
+ Assert.deepEqual(
+ await PreferenceRollouts.getAll(),
+ [{
+ slug: "test-rollout-1",
+ state: PreferenceRollouts.STATE_ACTIVE,
+ preferences: [
+ {preferenceName: "test.pref1", value: 1, previousValue: null},
+ {preferenceName: "test.pref2", value: 1, previousValue: null},
+ ],
+ }],
+ "Only recipe1's rollout should be stored in db",
+ );
+
+ Assert.deepEqual(
+ sendEventStub.args,
+ [
+ ["enroll", "preference_rollout", recipe1.arguments.slug, {}],
+ ["enrollFailed", "preference_rollout", recipe2.arguments.slug, {reason: "conflict", preference: "test.pref1"}],
+ ["enrollFailed", "preference_rollout", recipe2.arguments.slug, {reason: "conflict", preference: "test.pref1"}],
+ ]
+ );
+
+ // Cleanup
+ Services.prefs.getDefaultBranch("").deleteBranch("test.pref1");
+ Services.prefs.getDefaultBranch("").deleteBranch("test.pref2");
+ Services.prefs.getDefaultBranch("").deleteBranch("test.pref3");
+ },
+);
+
+// Test when the wrong value type is given, the recipe is not applied
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ withStub(TelemetryEvents, "sendEvent"),
+ async function wrong_preference_value(sendEventStub) {
+ Services.prefs.getDefaultBranch("").setCharPref("test.pref", "not an int");
+ const recipe = {
+ id: 1,
+ arguments: {
+ slug: "test-rollout",
+ preferences: [{preferenceName: "test.pref", value: 1}],
+ },
+ };
+
+ const action = new PreferenceRolloutAction();
+ await action.runRecipe(recipe);
+ await action.finalize();
+
+ is(Services.prefs.getCharPref("test.pref"), "not an int", "the pref should not be modified");
+ is(Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref"), Services.prefs.PREF_INVALID, "startup pref is not set");
+
+ Assert.deepEqual(await PreferenceRollouts.getAll(), [], "no rollout is stored in the db");
+ Assert.deepEqual(
+ sendEventStub.args,
+ [["enrollFailed", "preference_rollout", recipe.arguments.slug, {reason: "invalid type", pref: "test.pref"}]],
+ "an enrollment failed event should be sent",
+ );
+
+ // Cleanup
+ Services.prefs.getDefaultBranch("").deleteBranch("test.pref");
+ },
+);
+
+// Test that even when applying a rollout, user prefs are preserved
+decorate_task(
+ PreferenceRollouts.withTestMock,
+ async function preserves_user_prefs() {
+ Services.prefs.getDefaultBranch("").setCharPref("test.pref", "builtin value");
+ Services.prefs.setCharPref("test.pref", "user value");
+ const recipe = {
+ id: 1,
+ arguments: {
+ slug: "test-rollout",
+ preferences: [{preferenceName: "test.pref", value: "rollout value"}],
+ }
+ };
+
+ const action = new PreferenceRolloutAction();
+ await action.runRecipe(recipe);
+ await action.finalize();
+
+ is(Services.prefs.getCharPref("test.pref"), "user value", "user branch value should be preserved");
+ is(Services.prefs.getDefaultBranch("").getCharPref("test.pref"), "rollout value", "default branch value should change");
+
+ Assert.deepEqual(
+ await PreferenceRollouts.getAll(),
+ [{
+ slug: "test-rollout",
+ state: PreferenceRollouts.STATE_ACTIVE,
+ preferences: [{preferenceName: "test.pref", value: "rollout value", previousValue: "builtin value"}],
+ }],
+ "the rollout is added to the db with the correct previous value",
+ );
+
+ // Cleanup
+ Services.prefs.getDefaultBranch("").deleteBranch("test.pref");
+ Services.prefs.deleteBranch("test.pref");
+ },
+);