Bug 1620021 - Add ExperimentsManager and ExperimentAPI r=mythmon
Differential Revision: https://phabricator.services.mozilla.com/D65365
This commit is contained in:
21
toolkit/components/messaging-system/experiments/@types/ExperimentManager.d.ts
vendored
Normal file
21
toolkit/components/messaging-system/experiments/@types/ExperimentManager.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/* 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();
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
/* 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();
|
||||
@@ -0,0 +1,105 @@
|
||||
/* 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 });
|
||||
}
|
||||
}
|
||||
9
toolkit/components/messaging-system/jar.mn
Normal file
9
toolkit/components/messaging-system/jar.mn
Normal file
@@ -0,0 +1,9 @@
|
||||
# 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/*)
|
||||
97
toolkit/components/messaging-system/lib/SharedDataMap.jsm
Normal file
97
toolkit/components/messaging-system/lib/SharedDataMap.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";
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
toolkit/components/messaging-system/moz.build
Normal file
17
toolkit/components/messaging-system/moz.build
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- 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']
|
||||
45
toolkit/components/messaging-system/test/MSTestUtils.jsm
Normal file
45
toolkit/components/messaging-system/test/MSTestUtils.jsm
Normal file
@@ -0,0 +1,45 @@
|
||||
/* 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
5
toolkit/components/messaging-system/test/unit/head.js
Normal file
5
toolkit/components/messaging-system/test/unit/head.js
Normal file
@@ -0,0 +1,5 @@
|
||||
"use strict";
|
||||
// Globals
|
||||
|
||||
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
|
||||
const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
|
||||
@@ -0,0 +1,68 @@
|
||||
"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"
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
"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"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
"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"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
"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"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
"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");
|
||||
});
|
||||
10
toolkit/components/messaging-system/test/unit/xpcshell.ini
Normal file
10
toolkit/components/messaging-system/test/unit/xpcshell.ini
Normal file
@@ -0,0 +1,10 @@
|
||||
[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]
|
||||
@@ -119,7 +119,7 @@ if CONFIG['MOZ_WIDGET_TOOLKIT'] != 'android':
|
||||
]
|
||||
|
||||
if CONFIG['MOZ_BUILD_APP'] == 'browser':
|
||||
DIRS += ['normandy']
|
||||
DIRS += ['normandy', 'messaging-system']
|
||||
|
||||
# This is only packaged for browser since corrupt JAR and XPI files tend to be a desktop-OS problem.
|
||||
if CONFIG['MOZ_BUILD_APP'] == 'browser':
|
||||
|
||||
Reference in New Issue
Block a user