зеркало из https://github.com/mozilla/gecko-dev.git
Backed out 2 changesets (bug 1620021) for failures in test_ExperimentManager_lifecycle.js
Backed out changeset 600778a596aa (bug 1620021) Backed out changeset 50bc2329abae (bug 1620021)
This commit is contained in:
Родитель
652c78ccb3
Коммит
9562ef262a
|
@ -1,21 +0,0 @@
|
|||
export interface Branch {
|
||||
slug: string;
|
||||
ratio: number;
|
||||
groups: string[];
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface RecipeArgs {
|
||||
slug: string;
|
||||
isEnrollmentPaused?: boolean;
|
||||
experimentType?: string;
|
||||
branches: Branch[];
|
||||
}
|
||||
|
||||
export interface Enrollment {
|
||||
slug: string;
|
||||
enrollmentId: string;
|
||||
branch: Branch;
|
||||
active: boolean;
|
||||
experimentType: string;
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use strict";
|
||||
|
||||
const EXPORTED_SYMBOLS = ["ExperimentAPI"];
|
||||
|
||||
const { XPCOMUtils } = ChromeUtils.import(
|
||||
"resource://gre/modules/XPCOMUtils.jsm"
|
||||
);
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetters(this, {
|
||||
ExperimentStore:
|
||||
"resource://messaging-system/experiments/ExperimentStore.jsm",
|
||||
});
|
||||
|
||||
const ExperimentAPI = {
|
||||
/**
|
||||
* @returns {Promise} Resolves when the API has synchronized to the main store
|
||||
*/
|
||||
ready() {
|
||||
return this._store.ready();
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns an experiment, including all its metadata
|
||||
*
|
||||
* @param {{slug?: string, group?: string}} options slug = An experiment identifier
|
||||
* or group = a stable identifier for a group of experiments
|
||||
* @returns {Enrollment|undefined} A matching experiment if one is found.
|
||||
*/
|
||||
getExperiment({ slug, group } = {}) {
|
||||
if (slug) {
|
||||
return this._store.get(slug);
|
||||
} else if (group) {
|
||||
return this._store.getExperimentForGroup(group);
|
||||
}
|
||||
throw new Error("getExperiment(options) must include a slug or a group.");
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the value of the selected branch for a given experiment
|
||||
*
|
||||
* @param {{slug?: string, group?: string}} options slug = An experiment identifier
|
||||
* or group = a stable identifier for a group of experiments
|
||||
* @returns {any} The selected value of the active branch of the experiment
|
||||
*/
|
||||
getValue(options) {
|
||||
return this.getExperiment(options)?.branch.value;
|
||||
},
|
||||
};
|
||||
|
||||
XPCOMUtils.defineLazyGetter(ExperimentAPI, "_store", function() {
|
||||
return new ExperimentStore();
|
||||
});
|
|
@ -1,269 +0,0 @@
|
|||
/* 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";
|
||||
|
||||
/**
|
||||
* @typedef {import("./@types/ExperimentManager").RecipeArgs} RecipeArgs
|
||||
* @typedef {import("./@types/ExperimentManager").Enrollment} Enrollment
|
||||
* @typedef {import("./@types/ExperimentManager").Branch} Branch
|
||||
*/
|
||||
|
||||
const EXPORTED_SYMBOLS = ["ExperimentManager", "_ExperimentManager"];
|
||||
|
||||
const { XPCOMUtils } = ChromeUtils.import(
|
||||
"resource://gre/modules/XPCOMUtils.jsm"
|
||||
);
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetters(this, {
|
||||
ClientEnvironment: "resource://normandy/lib/ClientEnvironment.jsm",
|
||||
ExperimentStore:
|
||||
"resource://messaging-system/experiments/ExperimentStore.jsm",
|
||||
LogManager: "resource://normandy/lib/LogManager.jsm",
|
||||
NormandyUtils: "resource://normandy/lib/NormandyUtils.jsm",
|
||||
Sampling: "resource://gre/modules/components-utils/Sampling.jsm",
|
||||
TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
|
||||
TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.jsm",
|
||||
});
|
||||
|
||||
// This is included with event telemetry e.g. "enroll"
|
||||
// TODO: Add a new type called "messaging_study"
|
||||
const EVENT_TELEMETRY_STUDY_TYPE = "preference_study";
|
||||
// This is used by Telemetry.setExperimentActive
|
||||
const TELEMETRY_EXPERIMENT_TYPE_PREFIX = "normandy-";
|
||||
// Also included in telemetry
|
||||
const DEFAULT_EXPERIMENT_TYPE = "messaging_experiment";
|
||||
|
||||
/**
|
||||
* A module for processes Experiment recipes, choosing and storing enrollment state,
|
||||
* and sending experiment-related Telemetry.
|
||||
*/
|
||||
class _ExperimentManager {
|
||||
constructor({ id = "experimentmanager", storeId } = {}) {
|
||||
this.id = id;
|
||||
this.store = new ExperimentStore(storeId);
|
||||
this.slugsSeenInThisSession = new Set();
|
||||
this.log = LogManager.getLogger("ExperimentManager");
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs on startup, including before first run
|
||||
*/
|
||||
async onStartup() {
|
||||
const restoredExperiments = this.store.getAllActive();
|
||||
|
||||
for (const experiment of restoredExperiments) {
|
||||
this.setExperimentActive(experiment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every time a Recipe is updated or seen for the first time.
|
||||
* @param {RecipeArgs} recipe
|
||||
*/
|
||||
async onRecipe(recipe) {
|
||||
const { slug, isEnrollmentPaused } = recipe;
|
||||
|
||||
this.slugsSeenInThisSession.add(slug);
|
||||
|
||||
if (this.store.has(slug)) {
|
||||
this.updateEnrollment(recipe);
|
||||
} else if (isEnrollmentPaused) {
|
||||
this.log.debug(`Enrollment is paused for "${slug}"`);
|
||||
} else {
|
||||
await this.enroll(recipe);
|
||||
}
|
||||
}
|
||||
|
||||
// Runs when the all recipes been processed during an update, including at first run.
|
||||
onFinalize() {
|
||||
const activeExperiments = this.store.getAllActive();
|
||||
|
||||
for (const experiment of activeExperiments) {
|
||||
const { slug } = experiment;
|
||||
if (!this.slugsSeenInThisSession.has(slug)) {
|
||||
this.log.debug(`Stopping study for recipe ${slug}`);
|
||||
try {
|
||||
this.unenroll(slug, "recipe-not-seen");
|
||||
} catch (err) {
|
||||
Cu.reportError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.slugsSeenInThisSession.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new experiment by enrolling the users
|
||||
*
|
||||
* @param {RecipeArgs} recipe
|
||||
* @returns {Promise<Enrollment>} The experiment object stored in the data store
|
||||
* @rejects {Error}
|
||||
* @memberof _ExperimentManager
|
||||
*/
|
||||
async enroll({ slug, branches, experimentType = DEFAULT_EXPERIMENT_TYPE }) {
|
||||
if (this.store.has(slug)) {
|
||||
this.sendFailureTelemetry("enrollFailed", slug, "name-conflict");
|
||||
throw new Error(`An experiment with the slug "${slug}" already exists.`);
|
||||
}
|
||||
|
||||
const enrollmentId = NormandyUtils.generateUuid();
|
||||
const branch = await this.chooseBranch(slug, branches);
|
||||
|
||||
if (branch.groups && this.store.hasExperimentForGroups(branch.groups)) {
|
||||
this.log.debug(
|
||||
`Skipping enrollment for "${slug}" because there is an existing experiment for one of its groups.`
|
||||
);
|
||||
this.sendFailureTelemetry("enrollFailed", slug, "group-conflict");
|
||||
throw new Error(`An experiment with a conflicting group already exists.`);
|
||||
}
|
||||
|
||||
/** @type {Enrollment} */
|
||||
const experiment = {
|
||||
slug,
|
||||
branch,
|
||||
active: true,
|
||||
enrollmentId,
|
||||
experimentType,
|
||||
};
|
||||
|
||||
this.store.addExperiment(experiment);
|
||||
this.setExperimentActive(experiment);
|
||||
this.sendEnrollmentTelemetry(experiment);
|
||||
|
||||
this.log.debug(`New experiment started: ${slug}, ${branch.slug}`);
|
||||
|
||||
return experiment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an enrollment that was already set
|
||||
*
|
||||
* @param {RecipeArgs} recipe
|
||||
*/
|
||||
updateEnrollment(recipe) {
|
||||
/** @type Enrollment */
|
||||
const experiment = this.store.get(recipe.slug);
|
||||
|
||||
// Don't update experiments that were already unenrolled.
|
||||
if (experiment.active === false) {
|
||||
this.log.debug(`Enrollment ${recipe.slug} has expired, aborting.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stay in the same branch, don't re-sample every time.
|
||||
const branch = recipe.branches.find(
|
||||
branch => branch.slug === experiment.branch
|
||||
);
|
||||
|
||||
if (!branch) {
|
||||
// Our branch has been removed. Unenroll.
|
||||
this.unenroll(recipe.slug, "branch-removed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop an experiment that is currently active
|
||||
*
|
||||
* @param {string} slug
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.reason]
|
||||
*/
|
||||
unenroll(slug, { reason = "unknown" } = {}) {
|
||||
const experiment = this.store.get(slug);
|
||||
if (!experiment) {
|
||||
this.sendFailureTelemetry("unenrollFailed", slug, "does-not-exist");
|
||||
throw new Error(`Could not find an experiment with the slug "${slug}"`);
|
||||
}
|
||||
|
||||
if (!experiment.active) {
|
||||
this.sendFailureTelemetry("unenrollFailed", slug, "already-unenrolled");
|
||||
throw new Error(
|
||||
`Cannot stop experiment "${slug}" because it is already expired`
|
||||
);
|
||||
}
|
||||
|
||||
this.store.updateExperiment(slug, { active: false });
|
||||
|
||||
TelemetryEnvironment.setExperimentInactive(slug);
|
||||
TelemetryEvents.sendEvent("unenroll", EVENT_TELEMETRY_STUDY_TYPE, slug, {
|
||||
reason,
|
||||
branch: experiment.branch.slug,
|
||||
enrollmentId:
|
||||
experiment.enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
|
||||
});
|
||||
|
||||
this.log.debug(`Experiment unenrolled: ${slug}}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Telemetry for undesired event
|
||||
*
|
||||
* @param {string} eventName
|
||||
* @param {string} slug
|
||||
* @param {string} reason
|
||||
*/
|
||||
sendFailureTelemetry(eventName, slug, reason) {
|
||||
TelemetryEvents.sendEvent(eventName, EVENT_TELEMETRY_STUDY_TYPE, slug, {
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Enrollment} experiment
|
||||
*/
|
||||
sendEnrollmentTelemetry({ slug, branch, experimentType, enrollmentId }) {
|
||||
TelemetryEvents.sendEvent("enroll", EVENT_TELEMETRY_STUDY_TYPE, slug, {
|
||||
experimentType,
|
||||
branch: branch.slug,
|
||||
enrollmentId: enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets Telemetry when activating an experiment.
|
||||
*
|
||||
* @param {Enrollment} experiment
|
||||
* @memberof _ExperimentManager
|
||||
*/
|
||||
setExperimentActive(experiment) {
|
||||
TelemetryEnvironment.setExperimentActive(
|
||||
experiment.slug,
|
||||
experiment.branch.slug,
|
||||
{
|
||||
type: `${TELEMETRY_EXPERIMENT_TYPE_PREFIX}${experiment.experimentType}`,
|
||||
enrollmentId:
|
||||
experiment.enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose a branch randomly.
|
||||
*
|
||||
* @param {string} slug
|
||||
* @param {Branch[]} branches
|
||||
* @returns {Promise<Branch>}
|
||||
* @memberof _ExperimentManager
|
||||
*/
|
||||
async chooseBranch(slug, branches) {
|
||||
const ratios = branches.map(({ ratio = 1 }) => ratio);
|
||||
const userId = ClientEnvironment.userId;
|
||||
|
||||
// It's important that the input be:
|
||||
// - Unique per-user (no one is bucketed alike)
|
||||
// - Unique per-experiment (bucketing differs across multiple experiments)
|
||||
// - Differs from the input used for sampling the recipe (otherwise only
|
||||
// branches that contain the same buckets as the recipe sampling will
|
||||
// receive users)
|
||||
const input = `${this.id}-${userId}-${slug}-branch`;
|
||||
|
||||
const index = await Sampling.ratioSample(input, ratios);
|
||||
return branches[index];
|
||||
}
|
||||
}
|
||||
|
||||
const ExperimentManager = new _ExperimentManager();
|
|
@ -1,105 +0,0 @@
|
|||
/* 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";
|
||||
|
||||
/**
|
||||
* @typedef {import("../@types/ExperimentManager").Enrollment} Enrollment
|
||||
*/
|
||||
|
||||
const EXPORTED_SYMBOLS = ["ExperimentStore"];
|
||||
|
||||
const { SharedDataMap } = ChromeUtils.import(
|
||||
"resource://messaging-system/lib/SharedDataMap.jsm"
|
||||
);
|
||||
|
||||
const DEFAULT_STORE_ID = "ExperimentStoreData";
|
||||
|
||||
class ExperimentStore extends SharedDataMap {
|
||||
constructor(sharedDataKey) {
|
||||
super(sharedDataKey || DEFAULT_STORE_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a group identifier, find an active experiment that matches that group identifier.
|
||||
* For example, getExperimentForGroup("B") would return an experiment with groups ["A", "B", "C"]
|
||||
* This assumes, for now, that there is only one active experiment per group per browser.
|
||||
*
|
||||
* @param {string} group
|
||||
* @returns {Enrollment|undefined} An active experiment if it exists
|
||||
* @memberof ExperimentStore
|
||||
*/
|
||||
getExperimentForGroup(group) {
|
||||
for (const [, experiment] of this._map) {
|
||||
if (experiment.active && experiment.branch.groups?.includes(group)) {
|
||||
return experiment;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an active experiment already exists for a set of groups
|
||||
*
|
||||
* @param {Array<string>} groups
|
||||
* @returns {boolean} Does an active experiment exist for that group?
|
||||
* @memberof ExperimentStore
|
||||
*/
|
||||
hasExperimentForGroups(groups) {
|
||||
if (!groups || !groups.length) {
|
||||
return false;
|
||||
}
|
||||
for (const [, experiment] of this._map) {
|
||||
if (
|
||||
experiment.active &&
|
||||
experiment.branch.groups?.filter(g => groups.includes(g)).length
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Enrollment[]}
|
||||
*/
|
||||
getAll() {
|
||||
return [...this._map.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Enrollment[]}
|
||||
*/
|
||||
getAllActive() {
|
||||
return this.getAll().filter(experiment => experiment.active);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an experiment. Short form for .set(slug, experiment)
|
||||
* @param {Enrollment} experiment
|
||||
*/
|
||||
addExperiment(experiment) {
|
||||
if (!experiment || !experiment.slug) {
|
||||
throw new Error(
|
||||
`Tried to add an experiment but it didn't have a .slug property.`
|
||||
);
|
||||
}
|
||||
this.set(experiment.slug, experiment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new properties into the properties of an existing experiment
|
||||
* @param {string} slug
|
||||
* @param {Partial<Enrollment>} newProperties
|
||||
*/
|
||||
updateExperiment(slug, newProperties) {
|
||||
const oldProperties = this.get(slug);
|
||||
if (!oldProperties) {
|
||||
throw new Error(
|
||||
`Tried to update experiment ${slug} bug it doesn't exist`
|
||||
);
|
||||
}
|
||||
this.set(slug, { ...oldProperties, ...newProperties });
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
# 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/.
|
||||
|
||||
toolkit.jar:
|
||||
% resource messaging-system %res/messaging-system/
|
||||
res/messaging-system/experiments/ExperimentManager.jsm (./experiments/ExperimentManager.jsm)
|
||||
res/messaging-system/experiments/ExperimentStore.jsm (./experiments/ExperimentStore.jsm)
|
||||
res/messaging-system/lib/ (./lib/*)
|
|
@ -1,97 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use strict";
|
||||
|
||||
const EXPORTED_SYMBOLS = ["SharedDataMap"];
|
||||
|
||||
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
|
||||
ChromeUtils.defineModuleGetter(
|
||||
this,
|
||||
"PromiseUtils",
|
||||
"resource://gre/modules/PromiseUtils.jsm"
|
||||
);
|
||||
|
||||
const IS_MAIN_PROCESS =
|
||||
Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
|
||||
|
||||
class SharedDataMap {
|
||||
constructor(sharedDataKey) {
|
||||
this._sharedDataKey = sharedDataKey;
|
||||
this._isParent = IS_MAIN_PROCESS;
|
||||
this._isReady = this.isParent;
|
||||
this._readyDeferred = PromiseUtils.defer();
|
||||
this._map = null;
|
||||
|
||||
if (this.isParent) {
|
||||
this._map = new Map();
|
||||
this._syncToChildren({ flush: true });
|
||||
this._checkIfReady();
|
||||
} else {
|
||||
this._syncFromParent();
|
||||
Services.cpmm.sharedData.addEventListener("change", this);
|
||||
}
|
||||
}
|
||||
|
||||
get sharedDataKey() {
|
||||
return this._sharedDataKey;
|
||||
}
|
||||
|
||||
get isParent() {
|
||||
return this._isParent;
|
||||
}
|
||||
|
||||
ready() {
|
||||
return this._readyDeferred.promise;
|
||||
}
|
||||
|
||||
get(key) {
|
||||
return this._map.get(key);
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
if (!this.isParent) {
|
||||
throw new Error(
|
||||
"Setting values from within a content process is not allowed"
|
||||
);
|
||||
}
|
||||
this._map.set(key, value);
|
||||
this._syncToChildren();
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this._map.has(key);
|
||||
}
|
||||
|
||||
toObject() {
|
||||
return Object.fromEntries(this._map);
|
||||
}
|
||||
|
||||
_syncToChildren({ flush = false } = {}) {
|
||||
Services.ppmm.sharedData.set(this.sharedDataKey, this._map);
|
||||
if (flush) {
|
||||
Services.ppmm.sharedData.flush();
|
||||
}
|
||||
}
|
||||
|
||||
_syncFromParent() {
|
||||
this._map = Services.cpmm.sharedData.get(this.sharedDataKey);
|
||||
this._checkIfReady();
|
||||
}
|
||||
|
||||
_checkIfReady() {
|
||||
if (!this._isReady && this._map) {
|
||||
this._isReady = true;
|
||||
this._readyDeferred.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
handleEvent(event) {
|
||||
if (event.type === "change") {
|
||||
if (event.changedKeys.includes(this.sharedDataKey)) {
|
||||
this._syncFromParent();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
|
||||
# vim: set filetype=python:
|
||||
# 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/.
|
||||
|
||||
with Files('**'):
|
||||
BUG_COMPONENT = ('Firefox', 'Messaging System')
|
||||
|
||||
XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
|
||||
|
||||
TESTING_JS_MODULES += [
|
||||
'experiments/ExperimentAPI.jsm',
|
||||
'test/MSTestUtils.jsm'
|
||||
]
|
||||
|
||||
JAR_MANIFESTS += ['jar.mn']
|
|
@ -1,45 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use strict";
|
||||
|
||||
const { _ExperimentManager } = ChromeUtils.import(
|
||||
"resource://messaging-system/experiments/ExperimentManager.jsm"
|
||||
);
|
||||
const { ExperimentStore } = ChromeUtils.import(
|
||||
"resource://messaging-system/experiments/ExperimentStore.jsm"
|
||||
);
|
||||
const { NormandyUtils } = ChromeUtils.import(
|
||||
"resource://normandy/lib/NormandyUtils.jsm"
|
||||
);
|
||||
|
||||
const EXPORTED_SYMBOLS = ["ExperimentFakes"];
|
||||
|
||||
const ExperimentFakes = {
|
||||
manager() {
|
||||
return new _ExperimentManager({ storeId: "FakeStore" });
|
||||
},
|
||||
store() {
|
||||
return new ExperimentStore("FakeStore");
|
||||
},
|
||||
experiment(slug, props = {}) {
|
||||
return {
|
||||
slug,
|
||||
active: true,
|
||||
enrollmentId: NormandyUtils.generateUuid(),
|
||||
branch: { slug: "treatment", value: { title: "hello" } },
|
||||
...props,
|
||||
};
|
||||
},
|
||||
recipe(slug, props = {}) {
|
||||
return {
|
||||
slug,
|
||||
branches: [
|
||||
{ slug: "control", value: null },
|
||||
{ slug: "treatment", value: { title: "hello" } },
|
||||
],
|
||||
...props,
|
||||
};
|
||||
},
|
||||
};
|
|
@ -1,5 +0,0 @@
|
|||
"use strict";
|
||||
// Globals
|
||||
|
||||
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
|
||||
const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
|
|
@ -1,68 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const { ExperimentAPI } = ChromeUtils.import(
|
||||
"resource://testing-common/ExperimentAPI.jsm"
|
||||
);
|
||||
const { ExperimentFakes } = ChromeUtils.import(
|
||||
"resource://testing-common/MSTestUtils.jsm"
|
||||
);
|
||||
|
||||
/**
|
||||
* #getExperiment
|
||||
*/
|
||||
add_task(async function test_getExperiment_slug() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
const expected = ExperimentFakes.experiment("foo");
|
||||
manager.store.addExperiment(expected);
|
||||
|
||||
Assert.equal(
|
||||
ExperimentAPI.getExperiment(
|
||||
{ slug: "foo" },
|
||||
expected,
|
||||
"should return an experiment by slug"
|
||||
)
|
||||
);
|
||||
});
|
||||
add_task(async function test_getExperiment_group() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
const expected = ExperimentFakes.experiment("foo", {
|
||||
branch: { slug: "treatment", value: { title: "hi" }, groups: ["blue"] },
|
||||
});
|
||||
manager.store.addExperiment(expected);
|
||||
|
||||
Assert.equal(
|
||||
ExperimentAPI.getExperiment(
|
||||
{ group: "blue" },
|
||||
expected,
|
||||
"should return an experiment by slug"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* #getValue
|
||||
*/
|
||||
add_task(async function test_getValue() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
const value = { title: "hi" };
|
||||
const expected = ExperimentFakes.experiment("foo", {
|
||||
branch: { slug: "treatment", value },
|
||||
});
|
||||
manager.store.addExperiment(expected);
|
||||
|
||||
Assert.deepEqual(
|
||||
ExperimentAPI.getValue(
|
||||
{ slug: "foo" },
|
||||
value,
|
||||
"should return an experiment value by slug"
|
||||
)
|
||||
);
|
||||
|
||||
Assert.deepEqual(
|
||||
ExperimentAPI.getValue(
|
||||
{ slug: "doesnotexist" },
|
||||
undefined,
|
||||
"should return undefined if the experiment is not found"
|
||||
)
|
||||
);
|
||||
});
|
|
@ -1,128 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const { ExperimentFakes } = ChromeUtils.import(
|
||||
"resource://testing-common/MSTestUtils.jsm"
|
||||
);
|
||||
const { NormandyTestUtils } = ChromeUtils.import(
|
||||
"resource://testing-common/NormandyTestUtils.jsm"
|
||||
);
|
||||
|
||||
/**
|
||||
* The normal case: Enrollment of a new experiment
|
||||
*/
|
||||
add_task(async function test_add_to_store() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
const recipe = ExperimentFakes.recipe("foo");
|
||||
|
||||
await manager.enroll(recipe);
|
||||
const experiment = manager.store.get("foo");
|
||||
|
||||
Assert.ok(experiment, "should add an experiment with slug foo");
|
||||
Assert.ok(
|
||||
recipe.branches.includes(experiment.branch),
|
||||
"should choose a branch from the recipe.branches"
|
||||
);
|
||||
Assert.equal(experiment.active, true, "should set .active = true");
|
||||
Assert.ok(
|
||||
NormandyTestUtils.isUuid(experiment.enrollmentId),
|
||||
"should add a valid enrollmentId"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(
|
||||
async function test_setExperimentActive_sendEnrollmentTelemetry_called() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
const sandbox = sinon.sandbox.create();
|
||||
sandbox.spy(manager, "setExperimentActive");
|
||||
sandbox.spy(manager, "sendEnrollmentTelemetry");
|
||||
|
||||
await manager.enroll(ExperimentFakes.recipe("foo"));
|
||||
const experiment = manager.store.get("foo");
|
||||
|
||||
Assert.equal(
|
||||
manager.setExperimentActive.calledWith(experiment),
|
||||
true,
|
||||
"should call setExperimentActive after an enrollment"
|
||||
);
|
||||
|
||||
Assert.equal(
|
||||
manager.sendEnrollmentTelemetry.calledWith(experiment),
|
||||
true,
|
||||
"should call sendEnrollmentTelemetry after an enrollment"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Failure cases:
|
||||
* - slug conflict
|
||||
* - group conflict
|
||||
*/
|
||||
add_task(async function test_failure_name_conflict() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
const sandbox = sinon.sandbox.create();
|
||||
sandbox.spy(manager, "sendFailureTelemetry");
|
||||
|
||||
// simulate adding a previouly enrolled experiment
|
||||
manager.store.addExperiment(ExperimentFakes.experiment("foo"));
|
||||
|
||||
await Assert.rejects(
|
||||
manager.enroll(ExperimentFakes.recipe("foo")),
|
||||
/An experiment with the slug "foo" already exists/,
|
||||
"should throw if a conflicting experiment exists"
|
||||
);
|
||||
|
||||
Assert.equal(
|
||||
manager.sendFailureTelemetry.calledWith(
|
||||
"enrollFailed",
|
||||
"foo",
|
||||
"name-conflict"
|
||||
),
|
||||
true,
|
||||
"should send failure telemetry if a conflicting experiment exists"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function test_failure_group_conflict() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
const sandbox = sinon.sandbox.create();
|
||||
sandbox.spy(manager, "sendFailureTelemetry");
|
||||
|
||||
// Two conflicting branches that both have the group "pink"
|
||||
// These should not be allowed to exist simultaneously.
|
||||
const existingBranch = {
|
||||
slug: "treatment",
|
||||
groups: ["red", "pink"],
|
||||
value: { title: "hello" },
|
||||
};
|
||||
const newBranch = {
|
||||
slug: "treatment",
|
||||
groups: ["pink"],
|
||||
value: { title: "hi" },
|
||||
};
|
||||
|
||||
// simulate adding an experiment with a conflicting group "pink"
|
||||
manager.store.addExperiment(
|
||||
ExperimentFakes.experiment("foo", {
|
||||
branch: existingBranch,
|
||||
})
|
||||
);
|
||||
|
||||
// ensure .enroll chooses the special branch with the conflict
|
||||
sandbox.stub(manager, "chooseBranch").returns(newBranch);
|
||||
await Assert.rejects(
|
||||
manager.enroll(ExperimentFakes.recipe("bar", { branches: [newBranch] })),
|
||||
/An experiment with a conflicting group already exists/,
|
||||
"should throw if there is a group conflict"
|
||||
);
|
||||
|
||||
Assert.equal(
|
||||
manager.sendFailureTelemetry.calledWith(
|
||||
"enrollFailed",
|
||||
"bar",
|
||||
"group-conflict"
|
||||
),
|
||||
true,
|
||||
"should send failure telemetry if a group conflict exists"
|
||||
);
|
||||
});
|
|
@ -1,185 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const { _ExperimentManager } = ChromeUtils.import(
|
||||
"resource://messaging-system/experiments/ExperimentManager.jsm"
|
||||
);
|
||||
const { ExperimentStore } = ChromeUtils.import(
|
||||
"resource://messaging-system/experiments/ExperimentStore.jsm"
|
||||
);
|
||||
const { ExperimentFakes } = ChromeUtils.import(
|
||||
"resource://testing-common/MSTestUtils.jsm"
|
||||
);
|
||||
|
||||
/**
|
||||
* onStartup()
|
||||
* - should set call setExperimentActive for each active experiment
|
||||
*/
|
||||
add_task(async function test_onStartup_setExperimentActive_called() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
const sandbox = sinon.sandbox.create();
|
||||
sandbox.stub(manager, "setExperimentActive");
|
||||
|
||||
const active = ["foo", "bar"].map(ExperimentFakes.experiment);
|
||||
|
||||
const inactive = ["baz", "qux"].map(slug =>
|
||||
ExperimentFakes.experiment(slug, { active: false })
|
||||
);
|
||||
[...active, ...inactive].forEach(exp => manager.store.addExperiment(exp));
|
||||
|
||||
await manager.onStartup();
|
||||
|
||||
active.forEach(exp =>
|
||||
Assert.equal(
|
||||
manager.setExperimentActive.calledWith(exp),
|
||||
true,
|
||||
`should call setExperimentActive for active experiment: ${exp.slug}`
|
||||
)
|
||||
);
|
||||
|
||||
inactive.forEach(exp =>
|
||||
Assert.equal(
|
||||
manager.setExperimentActive.calledWith(exp),
|
||||
false,
|
||||
`should not call setExperimentActive for inactive experiment: ${exp.slug}`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* onRecipe()
|
||||
* - should add recipe slug to .slugsSeenInThisSession
|
||||
* - should call .enroll() if the recipe hasn't been seen before;
|
||||
* - should call .update() if the Enrollment already exists in the store;
|
||||
* - should skip enrollment if recipe.isEnrollmentPaused is true
|
||||
*/
|
||||
add_task(async function test_onRecipe_track_slug() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
const sandbox = sinon.sandbox.create();
|
||||
sandbox.spy(manager, "enroll");
|
||||
sandbox.spy(manager, "updateEnrollment");
|
||||
|
||||
const fooRecipe = ExperimentFakes.recipe("foo");
|
||||
|
||||
// The first time a recipe has seen;
|
||||
await manager.onRecipe(fooRecipe);
|
||||
|
||||
Assert.equal(
|
||||
manager.slugsSeenInThisSession.has("foo"),
|
||||
true,
|
||||
"should add slug to slugsSeenInThisSession"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function test_onRecipe_enroll() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
const sandbox = sinon.sandbox.create();
|
||||
sandbox.spy(manager, "enroll");
|
||||
sandbox.spy(manager, "updateEnrollment");
|
||||
|
||||
const fooRecipe = ExperimentFakes.recipe("foo");
|
||||
|
||||
await manager.onRecipe(fooRecipe);
|
||||
|
||||
Assert.equal(
|
||||
manager.enroll.calledWith(fooRecipe),
|
||||
true,
|
||||
"should call .enroll() the first time a recipe is seen"
|
||||
);
|
||||
Assert.equal(
|
||||
manager.store.has("foo"),
|
||||
true,
|
||||
"should add recipe to the store"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function test_onRecipe_update() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
const sandbox = sinon.sandbox.create();
|
||||
sandbox.spy(manager, "enroll");
|
||||
sandbox.spy(manager, "updateEnrollment");
|
||||
|
||||
const fooRecipe = ExperimentFakes.recipe("foo");
|
||||
|
||||
await manager.onRecipe(fooRecipe);
|
||||
// Call again after recipe has already been enrolled
|
||||
await manager.onRecipe(fooRecipe);
|
||||
|
||||
Assert.equal(
|
||||
manager.updateEnrollment.calledWith(fooRecipe),
|
||||
true,
|
||||
"should call .updateEnrollment() if the recipe has already been enrolled"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function test_onRecipe_isEnrollmentPaused() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
const sandbox = sinon.sandbox.create();
|
||||
sandbox.spy(manager, "enroll");
|
||||
sandbox.spy(manager, "updateEnrollment");
|
||||
|
||||
const pausedRecipe = ExperimentFakes.recipe("xyz", {
|
||||
isEnrollmentPaused: true,
|
||||
});
|
||||
await manager.onRecipe(pausedRecipe);
|
||||
Assert.equal(
|
||||
manager.enroll.calledWith(pausedRecipe),
|
||||
false,
|
||||
"should skip enrollment for recipes that are paused"
|
||||
);
|
||||
Assert.equal(
|
||||
manager.store.has("xyz"),
|
||||
false,
|
||||
"should not add recipe to the store"
|
||||
);
|
||||
|
||||
const fooRecipe = ExperimentFakes.recipe("foo");
|
||||
const updatedRecipe = ExperimentFakes.recipe("foo", {
|
||||
isEnrollmentPaused: true,
|
||||
});
|
||||
await manager.enroll(fooRecipe);
|
||||
await manager.onRecipe(updatedRecipe);
|
||||
Assert.equal(
|
||||
manager.updateEnrollment.calledWith(updatedRecipe),
|
||||
true,
|
||||
"should still update existing recipes, even if enrollment is paused"
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* onFinalize()
|
||||
* - should unenroll experiments that weren't seen in the current session
|
||||
*/
|
||||
|
||||
add_task(async function test_onFinalize_unenroll() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
const sandbox = sinon.sandbox.create();
|
||||
sandbox.spy(manager, "unenroll");
|
||||
|
||||
// Add an experiment to the store without calling .onRecipe
|
||||
// This simulates an enrollment having happened in the past.
|
||||
manager.store.addExperiment(ExperimentFakes.experiment("foo"));
|
||||
|
||||
// Simulate adding some other recipes
|
||||
await manager.onStartup();
|
||||
await manager.onRecipe(ExperimentFakes.recipe("bar"));
|
||||
await manager.onRecipe(ExperimentFakes.recipe("baz"));
|
||||
|
||||
// Finalize
|
||||
manager.onFinalize();
|
||||
|
||||
Assert.equal(
|
||||
manager.unenroll.callCount,
|
||||
1,
|
||||
"should only call unenroll for the unseen recipe"
|
||||
);
|
||||
Assert.equal(
|
||||
manager.unenroll.calledWith("foo", "recipe-not-seen"),
|
||||
true,
|
||||
"should unenroll a experiment whose recipe wasn't seen in the current session"
|
||||
);
|
||||
Assert.equal(
|
||||
manager.slugsSeenInThisSession.size,
|
||||
0,
|
||||
"should clear slugsSeenInThisSession"
|
||||
);
|
||||
});
|
|
@ -1,99 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const { ExperimentFakes } = ChromeUtils.import(
|
||||
"resource://testing-common/MSTestUtils.jsm"
|
||||
);
|
||||
const { NormandyTestUtils } = ChromeUtils.import(
|
||||
"resource://testing-common/NormandyTestUtils.jsm"
|
||||
);
|
||||
const { TelemetryEvents } = ChromeUtils.import(
|
||||
"resource://normandy/lib/TelemetryEvents.jsm"
|
||||
);
|
||||
const { TelemetryEnvironment } = ChromeUtils.import(
|
||||
"resource://gre/modules/TelemetryEnvironment.jsm"
|
||||
);
|
||||
|
||||
const globalSandbox = sinon.sandbox.create();
|
||||
globalSandbox.spy(TelemetryEnvironment, "setExperimentInactive");
|
||||
globalSandbox.spy(TelemetryEvents, "sendEvent");
|
||||
registerCleanupFunction(() => {
|
||||
globalSandbox.restore();
|
||||
});
|
||||
|
||||
/**
|
||||
* Normal unenrollment:
|
||||
* - set .active to false
|
||||
* - set experiment inactive in telemetry
|
||||
* - send unrollment event
|
||||
*/
|
||||
add_task(async function test_set_inactive() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
manager.store.addExperiment(ExperimentFakes.experiment("foo"));
|
||||
|
||||
manager.unenroll("foo", { reason: "some-reason" });
|
||||
|
||||
Assert.equal(
|
||||
manager.store.get("foo").active,
|
||||
false,
|
||||
"should set .active to false"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function test_setExperimentInactive_called() {
|
||||
globalSandbox.reset();
|
||||
const manager = ExperimentFakes.manager();
|
||||
const experiment = ExperimentFakes.experiment("foo");
|
||||
manager.store.addExperiment(experiment);
|
||||
|
||||
manager.unenroll("foo", { reason: "some-reason" });
|
||||
|
||||
Assert.ok(
|
||||
TelemetryEnvironment.setExperimentInactive.calledWith("foo"),
|
||||
"should call TelemetryEnvironment.setExperimentInactive with slug"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function test_send_unenroll_event() {
|
||||
globalSandbox.reset();
|
||||
const manager = ExperimentFakes.manager();
|
||||
const experiment = ExperimentFakes.experiment("foo");
|
||||
manager.store.addExperiment(experiment);
|
||||
|
||||
manager.unenroll("foo", { reason: "some-reason" });
|
||||
|
||||
Assert.ok(TelemetryEvents.sendEvent.calledOnce);
|
||||
Assert.deepEqual(
|
||||
TelemetryEvents.sendEvent.firstCall.args,
|
||||
[
|
||||
"unenroll",
|
||||
"preference_study", // This needs to be updated eventually
|
||||
"foo", // slug
|
||||
{
|
||||
reason: "some-reason",
|
||||
branch: experiment.branch.slug,
|
||||
enrollmentId: experiment.enrollmentId,
|
||||
},
|
||||
],
|
||||
"should send an unenrollment ping with the slug, reason, branch slug, and enrollmentId"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function test_undefined_reason() {
|
||||
globalSandbox.reset();
|
||||
const manager = ExperimentFakes.manager();
|
||||
const experiment = ExperimentFakes.experiment("foo");
|
||||
manager.store.addExperiment(experiment);
|
||||
|
||||
manager.unenroll("foo");
|
||||
|
||||
const options = TelemetryEvents.sendEvent.firstCall?.args[3];
|
||||
Assert.ok(
|
||||
"reason" in options,
|
||||
"options object with .reason should be the fourth param"
|
||||
);
|
||||
Assert.equal(
|
||||
options.reason,
|
||||
"unknown",
|
||||
"should include unknown as the reason if none was supplied"
|
||||
);
|
||||
});
|
|
@ -1,109 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const { ExperimentFakes } = ChromeUtils.import(
|
||||
"resource://testing-common/MSTestUtils.jsm"
|
||||
);
|
||||
|
||||
add_task(async function test_getExperimentForGroup() {
|
||||
const store = ExperimentFakes.store();
|
||||
const experiment = ExperimentFakes.experiment("foo", {
|
||||
branch: { slug: "variant", groups: ["green"] },
|
||||
});
|
||||
store.addExperiment(ExperimentFakes.experiment("bar"));
|
||||
store.addExperiment(experiment);
|
||||
|
||||
Assert.equal(
|
||||
store.getExperimentForGroup("green"),
|
||||
experiment,
|
||||
"should return a matching experiment for the given group"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function test_hasExperimentForGroups() {
|
||||
const store = ExperimentFakes.store();
|
||||
store.addExperiment(
|
||||
ExperimentFakes.experiment("foo", {
|
||||
branch: { slug: "variant", groups: ["green"] },
|
||||
})
|
||||
);
|
||||
store.addExperiment(
|
||||
ExperimentFakes.experiment("foo2", {
|
||||
branch: { slug: "variant", groups: ["yellow", "orange"] },
|
||||
})
|
||||
);
|
||||
store.addExperiment(
|
||||
ExperimentFakes.experiment("bar_expired", {
|
||||
active: false,
|
||||
branch: { slug: "variant", groups: ["purple"] },
|
||||
})
|
||||
);
|
||||
Assert.equal(
|
||||
store.hasExperimentForGroups([]),
|
||||
false,
|
||||
"should return false if the input is an empty array"
|
||||
);
|
||||
|
||||
Assert.equal(
|
||||
store.hasExperimentForGroups(["green", "blue"]),
|
||||
true,
|
||||
"should return true if there is an experiment with any of the given groups"
|
||||
);
|
||||
|
||||
Assert.equal(
|
||||
store.hasExperimentForGroups(["black", "yellow"]),
|
||||
true,
|
||||
"should return true if there is one of an experiment's multiple groups matches any of the given groups"
|
||||
);
|
||||
|
||||
Assert.equal(
|
||||
store.hasExperimentForGroups(["purple"]),
|
||||
false,
|
||||
"should return false if there is a non-active experiment with the given groups"
|
||||
);
|
||||
|
||||
Assert.equal(
|
||||
store.hasExperimentForGroups(["blue", "red"]),
|
||||
false,
|
||||
"should return false if none of the experiments have the given groups"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function test_getAll_getAllActive() {
|
||||
const store = ExperimentFakes.store();
|
||||
["foo", "bar", "baz"].forEach(slug =>
|
||||
store.addExperiment(ExperimentFakes.experiment(slug, { active: false }))
|
||||
);
|
||||
store.addExperiment(ExperimentFakes.experiment("qux", { active: true }));
|
||||
|
||||
Assert.deepEqual(
|
||||
store.getAll().map(e => e.slug),
|
||||
["foo", "bar", "baz", "qux"],
|
||||
".getAll() should return all experiments"
|
||||
);
|
||||
Assert.deepEqual(
|
||||
store.getAllActive().map(e => e.slug),
|
||||
["qux"],
|
||||
".getAllActive() should return all experiments that are active"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function test_addExperiment() {
|
||||
const store = ExperimentFakes.store();
|
||||
const exp = ExperimentFakes.experiment("foo");
|
||||
store.addExperiment(exp);
|
||||
|
||||
Assert.equal(store.get("foo"), exp, "should save experiment by slug");
|
||||
});
|
||||
|
||||
add_task(async function test_updateExperiment() {
|
||||
const experiment = Object.freeze(
|
||||
ExperimentFakes.experiment("foo", { value: true, active: true })
|
||||
);
|
||||
const store = ExperimentFakes.store();
|
||||
store.addExperiment(experiment);
|
||||
store.updateExperiment("foo", { active: false });
|
||||
|
||||
const actual = store.get("foo");
|
||||
Assert.equal(actual.active, false, "should change updated props");
|
||||
Assert.equal(actual.value, true, "should not update other props");
|
||||
});
|
|
@ -1,10 +0,0 @@
|
|||
[DEFAULT]
|
||||
head = head.js
|
||||
tags = messaging-system
|
||||
firefox-appdir = browser
|
||||
|
||||
[test_ExperimentAPI.js]
|
||||
[test_ExperimentManager_enroll.js]
|
||||
[test_ExperimentManager_lifecycle.js]
|
||||
[test_ExperimentManager_unenroll.js]
|
||||
[test_ExperimentStore.js]
|
|
@ -45,7 +45,6 @@ DIRS += [
|
|||
'kvstore',
|
||||
'lz4',
|
||||
'mediasniffer',
|
||||
'messaging-system',
|
||||
'mozintl',
|
||||
'mozprotocol',
|
||||
'osfile',
|
||||
|
|
|
@ -21,8 +21,6 @@ XPCOMUtils.defineLazyModuleGetters(this, {
|
|||
ShieldPreferences: "resource://normandy/lib/ShieldPreferences.jsm",
|
||||
TelemetryUtils: "resource://gre/modules/TelemetryUtils.jsm",
|
||||
TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
|
||||
ExperimentManager:
|
||||
"resource://messaging-system/experiments/ExperimentManager.jsm",
|
||||
});
|
||||
|
||||
var EXPORTED_SYMBOLS = ["Normandy"];
|
||||
|
@ -106,12 +104,6 @@ var Normandy = {
|
|||
Services.prefs.removeObserver(PREF_LOGGING_LEVEL, LogManager.configure)
|
||||
);
|
||||
|
||||
try {
|
||||
await ExperimentManager.onStartup();
|
||||
} catch (err) {
|
||||
log.error("Failed to initialize ExperimentManager:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
await AddonStudies.init();
|
||||
} catch (err) {
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use strict";
|
||||
|
||||
const { BaseStudyAction } = ChromeUtils.import(
|
||||
"resource://normandy/actions/BaseStudyAction.jsm"
|
||||
);
|
||||
|
||||
ChromeUtils.defineModuleGetter(
|
||||
this,
|
||||
"ExperimentManager",
|
||||
"resource://messaging-system/experiments/ExperimentManager.jsm"
|
||||
);
|
||||
|
||||
ChromeUtils.defineModuleGetter(
|
||||
this,
|
||||
"ActionSchemas",
|
||||
"resource://normandy/actions/schemas/index.js"
|
||||
);
|
||||
|
||||
const EXPORTED_SYMBOLS = ["MessagingExperimentAction"];
|
||||
|
||||
class MessagingExperimentAction extends BaseStudyAction {
|
||||
constructor() {
|
||||
super();
|
||||
this.manager = ExperimentManager;
|
||||
}
|
||||
get schema() {
|
||||
return ActionSchemas["messaging-experiment"];
|
||||
}
|
||||
|
||||
async _run(recipe) {
|
||||
if (recipe.arguments) {
|
||||
await this.manager.onRecipe(recipe.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
async _finalize() {
|
||||
this.manager.onFinalize();
|
||||
}
|
||||
}
|
|
@ -19,62 +19,6 @@ const ActionSchemas = {
|
|||
},
|
||||
},
|
||||
|
||||
"messaging-experiment": {
|
||||
$schema: "http://json-schema.org/draft-04/schema#",
|
||||
title: "Messaging Experiment",
|
||||
type: "object",
|
||||
required: ["slug", "branches"],
|
||||
properties: {
|
||||
slug: {
|
||||
description: "Unique identifier for this experiment",
|
||||
type: "string",
|
||||
pattern: "^[A-Za-z0-9\\-_]+$",
|
||||
},
|
||||
isEnrollmentPaused: {
|
||||
description: "If true, new users will not be enrolled in the study.",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
branches: {
|
||||
description: "List of experimental branches",
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["slug", "value", "ratio"],
|
||||
properties: {
|
||||
slug: {
|
||||
description:
|
||||
"Unique identifier for this branch of the experiment",
|
||||
type: "string",
|
||||
pattern: "^[A-Za-z0-9\\-_]+$",
|
||||
},
|
||||
value: {
|
||||
description: "Message content",
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
ratio: {
|
||||
description:
|
||||
"Ratio of users who should be grouped into this branch",
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
},
|
||||
groups: {
|
||||
description:
|
||||
"A list of experiment groups that can be used to exclude or select related experiments",
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
description: "Identifier of the group",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"preference-rollout": {
|
||||
$schema: "http://json-schema.org/draft-04/schema#",
|
||||
title: "Change preferences permanently",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@mozilla/normandy-action-argument-schemas",
|
||||
"version": "0.10.0",
|
||||
"version": "0.9.0",
|
||||
"description": "Schemas for Normandy action arguments",
|
||||
"main": "index.js",
|
||||
"author": "Michael Cooper <mcooper@mozilla.com>",
|
||||
|
|
|
@ -16,8 +16,6 @@ XPCOMUtils.defineLazyModuleGetters(this, {
|
|||
BranchedAddonStudyAction:
|
||||
"resource://normandy/actions/BranchedAddonStudyAction.jsm",
|
||||
ConsoleLogAction: "resource://normandy/actions/ConsoleLogAction.jsm",
|
||||
MessagingExperimentAction:
|
||||
"resource://normandy/actions/MessagingExperimentAction.jsm",
|
||||
PreferenceExperimentAction:
|
||||
"resource://normandy/actions/PreferenceExperimentAction.jsm",
|
||||
PreferenceRollbackAction:
|
||||
|
@ -37,7 +35,6 @@ const actionConstructors = {
|
|||
"addon-rollout": AddonRolloutAction,
|
||||
"branched-addon-study": BranchedAddonStudyAction,
|
||||
"console-log": ConsoleLogAction,
|
||||
"messsaging-experiment": MessagingExperimentAction,
|
||||
"multi-preference-experiment": PreferenceExperimentAction,
|
||||
"preference-rollback": PreferenceRollbackAction,
|
||||
"preference-rollout": PreferenceRolloutAction,
|
||||
|
|
|
@ -16,7 +16,6 @@ head = head.js
|
|||
[browser_actions_AddonRolloutAction.js]
|
||||
[browser_actions_BranchedAddonStudyAction.js]
|
||||
[browser_actions_ConsoleLogAction.js]
|
||||
[browser_actions_MessagingExperimentAction.js]
|
||||
[browser_actions_PreferenceExperimentAction.js]
|
||||
[browser_actions_PreferenceRolloutAction.js]
|
||||
[browser_actions_PreferenceRollbackAction.js]
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const { BaseAction } = ChromeUtils.import(
|
||||
"resource://normandy/actions/BaseAction.jsm"
|
||||
);
|
||||
const { Uptake } = ChromeUtils.import("resource://normandy/lib/Uptake.jsm");
|
||||
const { MessagingExperimentAction } = ChromeUtils.import(
|
||||
"resource://normandy/actions/MessagingExperimentAction.jsm"
|
||||
);
|
||||
|
||||
const { _ExperimentManager, ExperimentManager } = ChromeUtils.import(
|
||||
"resource://messaging-system/experiments/ExperimentManager.jsm"
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withStudiesEnabled,
|
||||
withStub(Uptake, "reportRecipe"),
|
||||
async function arguments_are_validated(reportRecipe) {
|
||||
const action = new MessagingExperimentAction();
|
||||
|
||||
is(
|
||||
action.manager,
|
||||
ExperimentManager,
|
||||
"should set .manager to ExperimentManager singleton"
|
||||
);
|
||||
// Override this for the purposes of the test
|
||||
action.manager = new _ExperimentManager();
|
||||
const onRecipeStub = sinon.spy(action.manager, "onRecipe");
|
||||
|
||||
const recipe = {
|
||||
id: 1,
|
||||
arguments: {
|
||||
slug: "foo",
|
||||
branches: [
|
||||
{
|
||||
slug: "control",
|
||||
ratio: 1,
|
||||
groups: ["green"],
|
||||
value: { title: "hello" },
|
||||
},
|
||||
{
|
||||
slug: "variant",
|
||||
ratio: 1,
|
||||
groups: ["green"],
|
||||
value: { title: "world" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
ok(action.validateArguments(recipe.arguments), "should validate arguments");
|
||||
|
||||
await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
|
||||
await action.finalize();
|
||||
|
||||
Assert.deepEqual(reportRecipe.args, [[recipe, Uptake.RECIPE_SUCCESS]]);
|
||||
Assert.deepEqual(
|
||||
onRecipeStub.args,
|
||||
[[recipe.arguments]],
|
||||
"should call onRecipe with recipe args"
|
||||
);
|
||||
}
|
||||
);
|
Загрузка…
Ссылка в новой задаче