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:
Noemi Erli 2020-04-11 01:38:33 +03:00
Родитель 652c78ccb3
Коммит 9562ef262a
23 изменённых файлов: 1 добавлений и 1399 удалений

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

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