Bug 1440782 Part 2 - Add preference-rollout action to Normandy r=Gijs

MozReview-Commit-ID: 2ItLoSxlbC

--HG--
rename : toolkit/components/normandy/actions/ConsoleLog.jsm => toolkit/components/normandy/actions/ConsoleLogAction.jsm
rename : toolkit/components/normandy/test/browser/browser_action_ConsoleLog.js => toolkit/components/normandy/test/browser/browser_actions_ConsoleLogAction.js
extra : rebase_source : d07892f47eb952170088f3f96044ac0aae99f7ce
This commit is contained in:
Mike Cooper 2018-04-19 15:37:11 -07:00
parent ab318eabb2
commit 49425f6a65
21 changed files with 1300 additions and 135 deletions

View File

@ -14,6 +14,7 @@ XPCOMUtils.defineLazyModuleGetters(this, {
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",
@ -28,6 +29,7 @@ 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
@ -35,13 +37,15 @@ 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: {},
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);
@ -55,7 +59,8 @@ var Normandy = {
},
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));
@ -82,6 +87,12 @@ var Normandy = {
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) {
@ -101,6 +112,7 @@ var Normandy = {
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 {
@ -152,76 +164,85 @@ var Normandy = {
}
},
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;
},
};

View File

@ -28,7 +28,8 @@ class BaseAction {
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);
}
}
@ -85,7 +86,7 @@ class BaseAction {
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);
@ -117,10 +118,11 @@ class BaseAction {
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);
@ -132,7 +134,7 @@ class BaseAction {
* here. It will be executed once after all recipes have been
* processed.
*/
_finalize() {
async _finalize() {
// Does nothing, may be overridden
}
}

View File

@ -7,11 +7,11 @@
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) {

View File

@ -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();
}
}

View File

@ -1,23 +1,55 @@
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;

View File

@ -7,8 +7,7 @@ toolkit.jar:
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

View File

@ -10,8 +10,6 @@ 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.
@ -66,7 +64,6 @@ var ActionSandboxManager = class extends SandboxManager {
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;
}

View File

@ -3,9 +3,10 @@ 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"];
@ -27,7 +28,8 @@ class ActionsManager {
this.remoteActionSandboxes = {};
this.localActions = {
"console-log": new ConsoleLog(),
"console-log": new ConsoleLogAction(),
"preference-rollout": new PreferenceRolloutAction(),
};
}
@ -57,6 +59,9 @@ class ActionsManager {
Uptake.reportAction(action.name, status);
}
}
const actionNames = Object.keys(this.remoteActionSandboxes);
log.debug(`Fetched ${actionNames.length} actions from the server: ${actionNames.join(", ")}`);
}
async preExecution() {

View File

@ -341,8 +341,6 @@ var AddonStudies = {
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.`);
}

View File

@ -130,11 +130,6 @@ var NormandyApi = {
verifiedObjects.push(object);
}
log.debug(
`Fetched ${verifiedObjects.length} ${type} from the server:`,
verifiedObjects.map(r => r.name).join(", ")
);
return verifiedObjects;
},

View File

@ -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);
}
}
}
};

View File

@ -509,7 +509,7 @@ var PreferenceExperiments = {
experiment.expired = true;
store.saveSoon();
TelemetryEnvironment.setExperimentInactive(experimentName, experiment.branch);
TelemetryEnvironment.setExperimentInactive(experimentName);
TelemetryEvents.sendEvent("unenroll", "preference_study", experimentName, {
didResetValue: resetValue ? "true" : "false",
reason,
@ -552,9 +552,7 @@ var PreferenceExperiments = {
* @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));
},

View File

@ -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();
}
},
};

View File

@ -206,6 +206,11 @@ var RecipeRunner = {
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}"`);

View File

@ -15,15 +15,22 @@ const TelemetryEvents = {
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,
},
@ -33,6 +40,13 @@ const TelemetryEvents = {
extra_keys: ["reason", "didResetValue", "addonId", "addonVersion"],
record_on_release: true,
},
graduated: {
methods: ["graduated"],
objects: ["preference_rollout"],
extra_keys: [],
record_on_release: true,
},
});
},

View File

@ -8,7 +8,8 @@ head = head.js
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]
@ -23,6 +24,7 @@ skip-if = true # bug 1442712
[browser_Normandy.js]
[browser_NormandyDriver.js]
[browser_PreferenceExperiments.js]
[browser_PreferenceRollouts.js]
[browser_RecipeRunner.js]
[browser_ShieldPreferences.js]
[browser_Storage.js]
[browser_Storage.js]

View File

@ -3,6 +3,7 @@
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);
@ -16,10 +17,11 @@ 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(),
);
}
@ -31,7 +33,7 @@ decorate_task(
[`app.normandy.startupExperimentPrefs.${experimentPref3}`, "string"],
],
}),
async function testInitExperimentPrefs() {
async function testApplyStartupPrefs() {
const defaultBranch = Services.prefs.getDefaultBranch("");
for (const pref of [experimentPref1, experimentPref2, experimentPref3]) {
is(
@ -41,7 +43,7 @@ decorate_task(
);
}
Normandy.initExperimentPrefs();
Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs.");
ok(
defaultBranch.getBoolPref(experimentPref1),
@ -75,14 +77,14 @@ decorate_task(
["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.",
);
},
);
@ -93,14 +95,14 @@ decorate_task(
["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.",
);
},
);
@ -125,16 +127,9 @@ decorate_task(
// 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);
@ -142,24 +137,21 @@ decorate_task(
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",
);
Assert.deepEqual(
rolloutsRecordOriginalValueStub.args,
[[{"test.rollout-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);
}
},
);
@ -172,9 +164,9 @@ decorate_task(
],
}),
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");
},
);
@ -194,7 +186,7 @@ decorate_task(
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");
@ -202,13 +194,14 @@ decorate_task(
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");
@ -216,13 +209,14 @@ decorate_task(
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");
@ -230,6 +224,7 @@ decorate_task(
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");
}
);
@ -244,6 +239,22 @@ decorate_task(
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");
}
);

View File

@ -718,7 +718,7 @@ decorate_task(
"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,

View File

@ -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"
);
},
);

View File

@ -1,11 +1,11 @@
"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!"}};
@ -21,7 +21,7 @@ add_task(async function logging_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 {

View File

@ -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");
},
);