mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-29 15:52:07 +00:00
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:
parent
ab318eabb2
commit
49425f6a65
@ -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;
|
||||
},
|
||||
};
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
167
toolkit/components/normandy/actions/PreferenceRolloutAction.jsm
Normal file
167
toolkit/components/normandy/actions/PreferenceRolloutAction.jsm
Normal 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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -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.`);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
},
|
||||
|
||||
|
97
toolkit/components/normandy/lib/PrefUtils.jsm
Normal file
97
toolkit/components/normandy/lib/PrefUtils.jsm
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
@ -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));
|
||||
},
|
||||
|
243
toolkit/components/normandy/lib/PreferenceRollouts.jsm
Normal file
243
toolkit/components/normandy/lib/PreferenceRollouts.jsm
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
@ -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}"`);
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -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]
|
||||
|
@ -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");
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
);
|
||||
},
|
||||
);
|
@ -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 {
|
@ -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");
|
||||
},
|
||||
);
|
Loading…
Reference in New Issue
Block a user