Bug 1738286 - New schema and enrollment flow for rollouts r=k88hudson

Differential Revision: https://phabricator.services.mozilla.com/D129835
This commit is contained in:
Andrei Oprea 2021-12-09 17:58:18 +00:00
Родитель 8941bcbb1e
Коммит ef300c52d4
35 изменённых файлов: 1673 добавлений и 1441 удалений

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

@ -107,30 +107,41 @@ add_task(async function test_nimbus_experiments() {
add_task(async function test_remote_configuration() {
await ExperimentAPI.ready();
await ExperimentFakes.remoteDefaultsHelper({
feature: NimbusFeatures.aboutwelcome,
configuration: {
slug: "about:studies-configuration-slug",
variables: { enabled: true },
targeting: "true",
},
let doCleanup = await ExperimentFakes.enrollWithRollout({
featureId: NimbusFeatures.aboutwelcome.featureId,
value: { enabled: true },
});
await BrowserTestUtils.withNewTab(
{ gBrowser, url: "about:support" },
async function(browser) {
let featureId = await SpecialPowers.spawn(browser, [], async function() {
await ContentTaskUtils.waitForCondition(
() =>
content.document.querySelector(
"#remote-features-tbody tr:first-child td"
)?.innerText
);
return content.document.querySelector(
"#remote-features-tbody tr:first-child td"
).innerText;
});
ok(featureId.match("aboutwelcome"), "Rendered the expected featureId");
let [userFacingName, branch] = await SpecialPowers.spawn(
browser,
[],
async function() {
await ContentTaskUtils.waitForCondition(
() =>
content.document.querySelector(
"#remote-features-tbody tr:first-child td"
)?.innerText
);
let rolloutName = content.document.querySelector(
"#remote-features-tbody tr:first-child td"
).innerText;
let branchName = content.document.querySelector(
"#remote-features-tbody tr:first-child td:nth-child(2)"
).innerText;
return [rolloutName, branchName];
}
);
ok(
userFacingName.match("NimbusTestUtils"),
"Rendered the expected rollout"
);
ok(branch.match("aboutwelcome"), "Rendered the expected rollout branch");
}
);
await doCleanup();
});

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

@ -24,13 +24,9 @@ add_task(async function remote_disable() {
return;
}
await ExperimentFakes.remoteDefaultsHelper({
feature: NimbusFeatures.shellService,
configuration: {
slug: "shellService_remoteDisable",
variables: { disablePin: true, enabled: true },
targeting: "true",
},
let doCleanup = await ExperimentFakes.enrollWithRollout({
featureId: NimbusFeatures.shellService.featureId,
value: { disablePin: true, enabled: true },
});
Assert.equal(
@ -38,6 +34,8 @@ add_task(async function remote_disable() {
false,
"Pinning disabled via nimbus"
);
await doCleanup();
});
add_task(async function restore_default() {

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

@ -46,15 +46,11 @@ add_task(async function remote_disable() {
userChoiceStub.resetHistory();
setDefaultStub.resetHistory();
await ExperimentFakes.remoteDefaultsHelper({
feature: NimbusFeatures.shellService,
configuration: {
slug: "shellService_remoteDisable",
variables: {
setDefaultBrowserUserChoice: false,
enabled: true,
},
targeting: "true",
let doCleanup = await ExperimentFakes.enrollWithRollout({
featureId: NimbusFeatures.shellService.featureId,
value: {
setDefaultBrowserUserChoice: false,
enabled: true,
},
});
@ -65,6 +61,8 @@ add_task(async function remote_disable() {
"Set default with user choice disabled via nimbus"
);
Assert.ok(setDefaultStub.called, "Used plain set default insteead");
await doCleanup();
});
add_task(async function restore_default() {

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

@ -24,12 +24,10 @@ add_task(async function not_major_upgrade() {
add_task(async function remote_disabled() {
await ExperimentAPI.ready();
await ExperimentFakes.remoteDefaultsHelper({
feature: NimbusFeatures.upgradeDialog,
configuration: {
slug: "upgradeDialog_remoteDisabled",
variables: { enabled: false },
targeting: "true",
let doCleanup = await ExperimentFakes.enrollWithRollout({
featureId: NimbusFeatures.upgradeDialog.featureId,
value: {
enabled: false,
},
});
@ -47,15 +45,7 @@ add_task(async function remote_disabled() {
"disabled",
]);
// Re-enable back
await ExperimentFakes.remoteDefaultsHelper({
feature: NimbusFeatures.upgradeDialog,
configuration: {
slug: "upgradeDialog_remoteEnabled",
variables: { enabled: true },
targeting: "true",
},
});
await doCleanup();
});
add_task(async function enterprise_disabled() {

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

@ -161,19 +161,15 @@ class QSTestUtils {
this.info?.("initNimbusFeature awaiting ExperimentAPI.ready");
await ExperimentAPI.ready();
this.info?.(
"initNimbusFeature awaiting ExperimentFakes.remoteDefaultsHelper"
);
await ExperimentFakes.remoteDefaultsHelper({
feature: NimbusFeatures.urlbar,
configuration: {
slug: "QuickSuggestTestUtils",
variables: { enabled: true },
targeting: "true",
},
this.info?.("initNimbusFeature awaiting ExperimentFakes.enrollWithRollout");
let doCleanup = await ExperimentFakes.enrollWithRollout({
featureId: NimbusFeatures.urlbar.featureId,
value: { enabled: true },
});
this.info?.("initNimbusFeature done");
this.registerCleanupFunction(doCleanup);
}
/**

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

@ -23,8 +23,6 @@ XPCOMUtils.defineLazyModuleGetters(this, {
ExperimentStore: "resource://nimbus/lib/ExperimentStore.jsm",
ExperimentManager: "resource://nimbus/lib/ExperimentManager.jsm",
RemoteSettings: "resource://services-settings/remote-settings.js",
setTimeout: "resource://gre/modules/Timer.jsm",
clearTimeout: "resource://gre/modules/Timer.jsm",
FeatureManifest: "resource://nimbus/FeatureManifest.js",
AppConstants: "resource://gre/modules/AppConstants.jsm",
});
@ -129,10 +127,13 @@ const ExperimentAPI = {
},
/**
* Return experiment slug its status and the enrolled branch slug
* Does NOT send exposure event because you only have access to the slugs
* Used by getExperimentMetaData and getRolloutMetaData
*
* @param {{slug: string, featureId: string}} options Enrollment identifier
* @param isRollout Is enrollment an experiment or a rollout
* @returns {object} Enrollment metadata
*/
getExperimentMetaData({ slug, featureId }) {
getEnrollmentMetaData({ slug, featureId }, isRollout) {
if (!slug && !featureId) {
throw new Error(
"getExperiment(options) must include a slug or a feature."
@ -144,7 +145,11 @@ const ExperimentAPI = {
if (slug) {
experimentData = this._store.get(slug);
} else if (featureId) {
experimentData = this._store.getExperimentForFeature(featureId);
if (isRollout) {
experimentData = this._store.getRolloutForFeature(featureId);
} else {
experimentData = this._store.getExperimentForFeature(featureId);
}
}
} catch (e) {
Cu.reportError(e);
@ -160,6 +165,22 @@ const ExperimentAPI = {
return null;
},
/**
* Return experiment slug its status and the enrolled branch slug
* Does NOT send exposure event because you only have access to the slugs
*/
getExperimentMetaData(options) {
return this.getEnrollmentMetaData(options);
},
/**
* Return rollout slug its status and the enrolled branch slug
* Does NOT send exposure event because you only have access to the slugs
*/
getRolloutMetaData(options) {
return this.getEnrollmentMetaData(options, true);
},
/**
* Return FeatureConfig from first active experiment where it can be found
* @param {{slug: string, featureId: string }}
@ -324,11 +345,6 @@ class _ExperimentFeature {
);
}
this._didSendExposureEvent = false;
this._onRemoteReady = null;
this._waitForRemote = new Promise(
resolve => (this._onRemoteReady = resolve)
);
this._listenForRemoteDefaults = this._listenForRemoteDefaults.bind(this);
const variables = this.manifest?.variables || {};
Object.keys(variables).forEach(key => {
@ -349,36 +365,6 @@ class _ExperimentFeature {
);
}
});
/**
* There are multiple events that can resolve the wait for remote defaults:
* 1. The feature can receive data via the RS update cycle
* 2. The RS update cycle finished; no record exists for this feature
* 3. User was enrolled in an experiment that targets this feature, resolve
* because experiments take priority.
*/
ExperimentAPI._store.on(
"remote-defaults-finalized",
this._listenForRemoteDefaults
);
this.onUpdate(this._listenForRemoteDefaults);
}
_listenForRemoteDefaults(eventName, reason) {
if (
// When the update cycle finished
eventName === "remote-defaults-finalized" ||
// remote default or experiment available
reason === "experiment-updated" ||
reason === "remote-defaults-update"
) {
ExperimentAPI._store.off(
"remote-defaults-updated",
this._listenForRemoteDefaults
);
this.off(this._listenForRemoteDefaults);
this._onRemoteReady();
}
}
getPreferenceName(variable) {
@ -403,24 +389,10 @@ class _ExperimentFeature {
/**
* Wait for ExperimentStore to load giving access to experiment features that
* do not have a pref cache and wait for remote defaults to load from Remote
* Settings.
*
* @param {number} timeout Optional timeout parameter
* do not have a pref cache
*/
async ready(timeout) {
const REMOTE_DEFAULTS_TIMEOUT_MS = 15 * 1000; // 15 seconds
await ExperimentAPI.ready();
if (ExperimentAPI._store.hasRemoteDefaultsReady()) {
this._onRemoteReady();
} else {
let remoteTimeoutId = setTimeout(
this._onRemoteReady,
timeout || REMOTE_DEFAULTS_TIMEOUT_MS
);
await this._waitForRemote;
clearTimeout(remoteTimeoutId);
}
ready() {
return ExperimentAPI.ready();
}
/**
@ -441,10 +413,6 @@ class _ExperimentFeature {
return feature.enabled;
}
if (isBooleanValueDefined(this.getRemoteConfig()?.enabled)) {
return this.getRemoteConfig().enabled;
}
let enabled;
try {
enabled = this.getVariable("enabled");
@ -455,6 +423,10 @@ class _ExperimentFeature {
return enabled;
}
if (isBooleanValueDefined(this.getRollout()?.enabled)) {
return this.getRollout().enabled;
}
return defaultValue;
}
@ -474,7 +446,7 @@ class _ExperimentFeature {
return {
...this.prefGetters,
...defaultValues,
...this.getRemoteConfig()?.variables,
...this.getRollout()?.value,
...(featureValue || null),
...userPrefs,
};
@ -511,7 +483,7 @@ class _ExperimentFeature {
}
// Next, check remote defaults
const remoteValue = this.getRemoteConfig()?.variables?.[variable];
const remoteValue = this.getRollout()?.value?.[variable];
if (typeof remoteValue !== "undefined") {
return remoteValue;
}
@ -519,13 +491,26 @@ class _ExperimentFeature {
return prefValue;
}
getRemoteConfig() {
let remoteConfig = ExperimentAPI._store.getRemoteConfig(this.featureId);
getRollout() {
let remoteConfig = ExperimentAPI._store.getRolloutForFeature(
this.featureId
);
if (!remoteConfig) {
return null;
}
return remoteConfig;
if (remoteConfig.branch?.features) {
return remoteConfig.branch?.features.find(
f => f.featureId === this.featureId
);
}
// This path is deprecated and will be removed in the future
if (remoteConfig.branch?.feature) {
return remoteConfig.branch.feature;
}
return null;
}
recordExposureEvent({ once = false } = {}) {
@ -533,16 +518,21 @@ class _ExperimentFeature {
return;
}
let experimentData = ExperimentAPI.getExperiment({
let enrollmentData = ExperimentAPI.getExperimentMetaData({
featureId: this.featureId,
});
if (!enrollmentData) {
enrollmentData = ExperimentAPI.getRolloutMetaData({
featureId: this.featureId,
});
}
// Exposure only sent if user is enrolled in an experiment
if (experimentData) {
if (enrollmentData) {
ExperimentAPI.recordExposureEvent({
featureId: this.featureId,
experimentSlug: experimentData.slug,
branchSlug: experimentData.branch?.slug,
experimentSlug: enrollmentData.slug,
branchSlug: enrollmentData.branch?.slug,
});
this._didSendExposureEvent = true;
}
@ -570,7 +560,7 @@ class _ExperimentFeature {
this.prefGetters[prefName],
]),
userPrefs: this._getUserPrefsValues(),
remoteDefaults: this.getRemoteConfig(),
rollouts: this.getRollout(),
};
}
}

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

@ -80,6 +80,12 @@ class _ExperimentManager {
return this.store.getAllActive().map(exp => exp.slug);
},
});
Object.defineProperty(context, "activeRollouts", {
get: async () => {
await this.store.ready();
return this.store.getAllRollouts().map(rollout => rollout.slug);
},
});
return context;
}
@ -89,10 +95,14 @@ class _ExperimentManager {
async onStartup() {
await this.store.init();
const restoredExperiments = this.store.getAllActive();
const restoredRollouts = this.store.getAllRollouts();
for (const experiment of restoredExperiments) {
this.setExperimentActive(experiment);
}
for (const rollout of restoredRollouts) {
this.setExperimentActive(rollout);
}
}
/**
@ -123,19 +133,9 @@ class _ExperimentManager {
}
}
/**
* Runs when the all recipes been processed during an update, including at first run.
* @param {string} sourceToCheck
* @param {object} options Extra context used in telemetry reporting
*/
onFinalize(sourceToCheck, { recipeMismatches } = { recipeMismatches: [] }) {
if (!sourceToCheck) {
throw new Error("When calling onFinalize, you must specify a source.");
}
const activeExperiments = this.store.getAllActive();
for (const experiment of activeExperiments) {
const { slug, source } = experiment;
_checkUnseenEnrollments(enrollments, sourceToCheck, recipeMismatches) {
for (const enrollment of enrollments) {
const { slug, source } = enrollment;
if (sourceToCheck !== source) {
continue;
}
@ -151,6 +151,30 @@ class _ExperimentManager {
}
}
}
}
/**
* Removes stored enrollments that were not seen after syncing with Remote Settings
* Runs when the all recipes been processed during an update, including at first run.
* @param {string} sourceToCheck
* @param {object} options Extra context used in telemetry reporting
*/
onFinalize(sourceToCheck, { recipeMismatches } = { recipeMismatches: [] }) {
if (!sourceToCheck) {
throw new Error("When calling onFinalize, you must specify a source.");
}
const activeExperiments = this.store.getAllActive();
const activeRollouts = this.store.getAllRollouts();
this._checkUnseenEnrollments(
activeExperiments,
sourceToCheck,
recipeMismatches
);
this._checkUnseenEnrollments(
activeRollouts,
sourceToCheck,
recipeMismatches
);
this.sessions.delete(sourceToCheck);
}
@ -200,12 +224,17 @@ class _ExperimentManager {
throw new Error(`An experiment with the slug "${slug}" already exists.`);
}
let storeLookupByFeature = recipe.isRollout
? this.store.getRolloutForFeature.bind(this.store)
: this.store.hasExperimentForFeature.bind(this.store);
const branch = await this.chooseBranch(slug, branches);
const features = featuresCompat(branch);
for (let feature of features) {
if (this.store.hasExperimentForFeature(feature?.featureId)) {
if (storeLookupByFeature(feature?.featureId)) {
log.debug(
`Skipping enrollment for "${slug}" because there is an existing experiment for its feature.`
`Skipping enrollment for "${slug}" because there is an existing ${
recipe.isRollout ? "rollout" : "experiment"
} for this feature.`
);
this.sendFailureTelemetry("enrollFailed", slug, "feature-conflict");
@ -223,6 +252,7 @@ class _ExperimentManager {
userFacingName,
userFacingDescription,
featureIds,
isRollout,
},
branch,
source,
@ -235,6 +265,7 @@ class _ExperimentManager {
active: true,
enrollmentId: NormandyUtils.generateUuid(),
experimentType,
isRollout,
source,
userFacingName,
userFacingDescription,
@ -248,11 +279,21 @@ class _ExperimentManager {
experiment.force = true;
}
this.store.addExperiment(experiment);
this.setExperimentActive(experiment);
if (isRollout) {
experiment.experimentType = "rollout";
this.store.addEnrollment(experiment);
this.setExperimentActive(experiment);
} else {
this.store.addEnrollment(experiment);
this.setExperimentActive(experiment);
}
this.sendEnrollmentTelemetry(experiment);
log.debug(`New experiment started: ${slug}, ${branch.slug}`);
log.debug(
`New ${isRollout ? "rollout" : "experiment"} started: ${slug}, ${
branch.slug
}`
);
return experiment;
}
@ -267,6 +308,7 @@ class _ExperimentManager {
const features = featuresCompat(branch);
for (let feature of features) {
let experiment = this.store.getExperimentForFeature(feature?.featureId);
let rollout = this.store.getRolloutForFeature(feature?.featureId);
if (experiment) {
log.debug(
`Existing experiment found for the same feature ${feature.featureId}, unenrolling.`
@ -274,6 +316,13 @@ class _ExperimentManager {
this.unenroll(experiment.slug, source);
}
if (rollout) {
log.debug(
`Existing experiment found for the same feature ${feature.featureId}, unenrolling.`
);
this.unenroll(rollout.slug, source);
}
}
recipe.userFacingName = `${recipe.userFacingName} - Forced enrollment`;
@ -296,23 +345,25 @@ class _ExperimentManager {
*/
updateEnrollment(recipe) {
/** @type Enrollment */
const experiment = this.store.get(recipe.slug);
const enrollment = this.store.get(recipe.slug);
// Don't update experiments that were already unenrolled.
if (experiment.active === false) {
if (enrollment.active === false) {
log.debug(`Enrollment ${recipe.slug} has expired, aborting.`);
return;
return false;
}
// Stay in the same branch, don't re-sample every time.
const branch = recipe.branches.find(
branch => branch.slug === experiment.branch.slug
branch => branch.slug === enrollment.branch.slug
);
if (!branch) {
// Our branch has been removed. Unenroll.
this.unenroll(recipe.slug, "branch-removed");
}
return true;
}
/**
@ -322,30 +373,30 @@ class _ExperimentManager {
* @param {string} reason
*/
unenroll(slug, reason = "unknown") {
const experiment = this.store.get(slug);
if (!experiment) {
const enrollment = this.store.get(slug);
if (!enrollment) {
this.sendFailureTelemetry("unenrollFailed", slug, "does-not-exist");
throw new Error(`Could not find an experiment with the slug "${slug}"`);
}
if (!experiment.active) {
if (!enrollment.active) {
this.sendFailureTelemetry("unenrollFailed", slug, "already-unenrolled");
throw new Error(
`Cannot stop experiment "${slug}" because it is already expired`
);
}
TelemetryEnvironment.setExperimentInactive(slug);
this.store.updateExperiment(slug, { active: false });
TelemetryEnvironment.setExperimentInactive(slug);
TelemetryEvents.sendEvent("unenroll", TELEMETRY_EVENT_OBJECT, slug, {
reason,
branch: experiment.branch.slug,
branch: enrollment.branch.slug,
enrollmentId:
experiment.enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
enrollment.enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
});
log.debug(`Experiment unenrolled: ${slug}`);
log.debug(`Recipe unenrolled: ${slug}`);
}
/**
@ -402,39 +453,6 @@ class _ExperimentManager {
);
}
/**
* Returns identifier for Telemetry experiment environment
*
* @param {string} featureId e.g. "aboutwelcome"
* @returns {string} the identifier, e.g. "default-aboutwelcome"
*/
getRemoteDefaultTelemetryIdentifierForFeature(featureId) {
return `default-${featureId}`;
}
/**
* Sets Telemetry when activating a remote default.
*
* @param {featureId} string The feature identifier e.g. "aboutwelcome"
* @param {configId} string The identifier of the active configuration
*/
setRemoteDefaultActive(featureId, configId) {
TelemetryEnvironment.setExperimentActive(
this.getRemoteDefaultTelemetryIdentifierForFeature(featureId),
configId,
{
type: `${TELEMETRY_EXPERIMENT_ACTIVE_PREFIX}default`,
enrollmentId: TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
}
);
}
setRemoteDefaultInactive(featureId) {
TelemetryEnvironment.setExperimentInactive(
this.getRemoteDefaultTelemetryIdentifierForFeature(featureId)
);
}
/**
* Generate Normandy UserId respective to a branch
* for a given experiment.

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

@ -20,7 +20,6 @@ XPCOMUtils.defineLazyModuleGetters(this, {
const IS_MAIN_PROCESS =
Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
const REMOTE_DEFAULTS_KEY = "__REMOTE_DEFAULTS";
// This branch is used to store experiment data
const SYNC_DATA_PREF_BRANCH = "nimbus.syncdatastore.";
@ -125,7 +124,10 @@ XPCOMUtils.defineLazyGetter(this, "syncDataStore", () => {
return null;
}
let prefBranch = `${SYNC_DEFAULTS_PREF_BRANCH}${featureId}.`;
metadata.variables = this._getBranchChildValues(prefBranch, featureId);
metadata.branch.feature.value = this._getBranchChildValues(
prefBranch,
featureId
);
return metadata;
},
@ -157,19 +159,26 @@ XPCOMUtils.defineLazyGetter(this, "syncDataStore", () => {
this._trySetPrefValue(experimentsPrefBranch, featureId, value);
}
},
setDefault(featureId, value) {
setDefault(featureId, enrollment) {
/* We store configuration variables separately in pref branches of
* appropriate type:
* (feature: "foo") { variables: { enabled: true } }
* gets stored as `${SYNC_DEFAULTS_PREF_BRANCH}foo.enabled=true`
*/
for (let variable of Object.keys(value.variables || {})) {
let { feature } = enrollment.branch;
for (let variable of Object.keys(feature.value)) {
let prefName = `${SYNC_DEFAULTS_PREF_BRANCH}${featureId}.${variable}`;
this._trySetTypedPrefValue(prefName, value.variables[variable]);
this._trySetTypedPrefValue(prefName, feature.value[variable]);
}
this._trySetPrefValue(defaultsPrefBranch, featureId, {
...value,
variables: null,
...enrollment,
branch: {
...enrollment.branch,
feature: {
...enrollment.branch.feature,
value: null,
},
},
});
},
getAllDefaultBranches() {
@ -238,6 +247,11 @@ class ExperimentStore extends SharedDataMap {
this._emitFeatureUpdate(featureId, "feature-experiment-loaded")
);
});
this.getAllRollouts().forEach(({ featureIds }) => {
featureIds.forEach(featureId =>
this._emitFeatureUpdate(featureId, "feature-rollout-loaded")
);
});
Services.tm.idleDispatchToMainThread(() => this._cleanupOldRecipes());
}
@ -296,7 +310,31 @@ class ExperimentStore extends SharedDataMap {
* @returns {Enrollment[]}
*/
getAllActive() {
return this.getAll().filter(experiment => experiment.active);
return this.getAll().filter(
enrollment => enrollment.active && !enrollment.isRollout
);
}
/**
* Returns all active rollouts
* @returns {array}
*/
getAllRollouts() {
return this.getAll().filter(
enrollment => enrollment.active && enrollment.isRollout
);
}
/**
* Query the store for the remote configuration of a feature
* @param {string} featureId The feature we want to query for
* @returns {{Rollout}|undefined} Remote defaults if available
*/
getRolloutForFeature(featureId) {
return (
this.getAllRollouts().find(r => r.featureIds.includes(featureId)) ||
syncDataStore.getDefault(featureId)
);
}
/**
@ -317,13 +355,16 @@ class ExperimentStore extends SharedDataMap {
this._removeEntriesByKeys(recipesToRemove.map(r => r.slug));
}
_emitExperimentUpdates(experiment) {
this.emit(`update:${experiment.slug}`, experiment);
_emitUpdates(enrollment) {
this.emit(`update:${enrollment.slug}`, enrollment);
(
experiment.featureIds || getAllBranchFeatureIds(experiment.branch)
enrollment.featureIds || getAllBranchFeatureIds(enrollment.branch)
).forEach(featureId => {
this.emit(`update:${featureId}`, experiment);
this._emitFeatureUpdate(featureId, "experiment-updated");
this.emit(`update:${featureId}`, enrollment);
this._emitFeatureUpdate(
featureId,
enrollment.isRollout ? "rollout-updated" : "experiment-updated"
);
});
}
@ -340,23 +381,31 @@ class ExperimentStore extends SharedDataMap {
}
/**
* @param {Enrollment} experiment
* Persists early startup experiments or rollouts
* @param {Enrollment} enrollment Experiment or rollout
*/
_updateSyncStore(experiment) {
let features = featuresCompat(experiment.branch);
_updateSyncStore(enrollment) {
let features = featuresCompat(enrollment.branch);
for (let feature of features) {
if (
FeatureManifest[feature.featureId]?.isEarlyStartup ||
feature.isEarlyStartup
) {
if (!experiment.active) {
if (!enrollment.active) {
// Remove experiments on un-enroll, no need to check if it exists
syncDataStore.delete(feature.featureId);
if (enrollment.isRollout) {
syncDataStore.deleteDefault(feature.featureId);
} else {
syncDataStore.delete(feature.featureId);
}
} else {
syncDataStore.set(feature.featureId, {
...experiment,
let updateEnrollmentSyncStore = enrollment.isRollout
? syncDataStore.setDefault.bind(syncDataStore)
: syncDataStore.set.bind(syncDataStore);
updateEnrollmentSyncStore(feature.featureId, {
...enrollment,
branch: {
...experiment.branch,
...enrollment.branch,
feature,
// Only store the early startup feature
features: null,
@ -368,18 +417,18 @@ class ExperimentStore extends SharedDataMap {
}
/**
* Add an experiment. Short form for .set(slug, experiment)
* @param {Enrollment} experiment
* Add an enrollment and notify listeners
* @param {Enrollment} enrollment
*/
addExperiment(experiment) {
if (!experiment || !experiment.slug) {
addEnrollment(enrollment) {
if (!enrollment || !enrollment.slug) {
throw new Error(
`Tried to add an experiment but it didn't have a .slug property.`
);
}
this.set(experiment.slug, experiment);
this._updateSyncStore(experiment);
this._emitExperimentUpdates(experiment);
this.set(enrollment.slug, enrollment);
this._updateSyncStore(enrollment);
this._emitUpdates(enrollment);
}
/**
@ -391,116 +440,24 @@ class ExperimentStore extends SharedDataMap {
const oldProperties = this.get(slug);
if (!oldProperties) {
throw new Error(
`Tried to update experiment ${slug} bug it doesn't exist`
`Tried to update experiment ${slug} but it doesn't exist`
);
}
const updatedExperiment = { ...oldProperties, ...newProperties };
this.set(slug, updatedExperiment);
this._updateSyncStore(updatedExperiment);
this._emitExperimentUpdates(updatedExperiment);
this._emitUpdates(updatedExperiment);
}
/**
* Remove any unused remote configurations and send the end event
* Test only helper for cleanup
*
* @param {Array} activeFeatureIds The set of all feature ids with matching configs during an update
* @memberof ExperimentStore
* @param slugOrFeatureId Can be called with slug (which removes the SharedDataMap entry) or
* with featureId which removes the SyncDataStore entry for the feature
*/
finalizeRemoteConfigs(activeFeatureConfigIds) {
if (!activeFeatureConfigIds) {
throw new Error("You must pass in an array of active feature ids.");
}
// If we haven't seen this feature in any of the configurations
// processed then we should clean up the matching pref cache and in-memory store
for (let featureId of this.getAllExistingRemoteConfigIds()) {
if (!activeFeatureConfigIds.includes(featureId)) {
this.deleteRemoteConfig(featureId);
}
}
// In case no features exist we want to at least initialize with an empty
// object to signal that we completed the initial fetch step
if (!activeFeatureConfigIds.length) {
// Wait for ready, in the case users have opted out we finalize early
// and things might not be ready yet
this.ready().then(() => this.setNonPersistent(REMOTE_DEFAULTS_KEY, {}));
}
// Notify all ExperimentFeature instances that the Remote Defaults cycle finished
// this will resolve the `onRemoteReady` promise for features that do not
// have any remote data available.
this.emit("remote-defaults-finalized");
}
/**
* Store the remote configuration once loaded from Remote Settings.
* @param {string} featureId The feature we want to update with remote defaults
* @param {object} configuration The remote value
*/
updateRemoteConfigs(featureId, configuration) {
const remoteConfigState = this.get(REMOTE_DEFAULTS_KEY);
this.setNonPersistent(REMOTE_DEFAULTS_KEY, {
...remoteConfigState,
[featureId]: { ...configuration },
});
if (
FeatureManifest[featureId]?.isEarlyStartup ||
configuration.isEarlyStartup
) {
syncDataStore.setDefault(featureId, configuration);
}
this._emitFeatureUpdate(featureId, "remote-defaults-update");
}
deleteRemoteConfig(featureId) {
const remoteConfigState = this.get(REMOTE_DEFAULTS_KEY);
delete remoteConfigState?.[featureId];
this.setNonPersistent(REMOTE_DEFAULTS_KEY, { ...remoteConfigState });
syncDataStore.deleteDefault(featureId);
this._emitFeatureUpdate(featureId, "remote-defaults-update");
}
/**
* Query the store for the remote configuration of a feature
* @param {string} featureId The feature we want to query for
* @returns {{RemoteDefaults}|undefined} Remote defaults if available
*/
getRemoteConfig(featureId) {
return (
this.get(REMOTE_DEFAULTS_KEY)?.[featureId] ||
syncDataStore.getDefault(featureId)
);
}
/**
* Get all existing active remote config ids
* @returns {Array<string>}
*/
getAllExistingRemoteConfigIds() {
return [
...new Set([
...syncDataStore.getAllDefaultBranches(),
...Object.keys(this.get(REMOTE_DEFAULTS_KEY) || {}),
]),
];
}
getAllRemoteConfigs() {
const remoteDefaults = this.get(REMOTE_DEFAULTS_KEY);
if (!remoteDefaults) {
return [];
}
let featureIds = Object.keys(remoteDefaults);
return Object.values(remoteDefaults).map((rc, idx) => ({
...rc,
featureId: featureIds[idx],
}));
}
_deleteForTests(featureId) {
super._deleteForTests(featureId);
syncDataStore.deleteDefault(featureId);
syncDataStore.delete(featureId);
_deleteForTests(slugOrFeatureId) {
super._deleteForTests(slugOrFeatureId);
syncDataStore.deleteDefault(slugOrFeatureId);
syncDataStore.delete(slugOrFeatureId);
}
}

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

@ -7,7 +7,6 @@
const EXPORTED_SYMBOLS = [
"_RemoteSettingsExperimentLoader",
"RemoteSettingsExperimentLoader",
"RemoteDefaultsLoader",
];
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
@ -39,7 +38,6 @@ XPCOMUtils.defineLazyServiceGetter(
const COLLECTION_ID_PREF = "messaging-system.rsexperimentloader.collection_id";
const COLLECTION_ID_FALLBACK = "nimbus-desktop-experiments";
const COLLECTION_REMOTE_DEFAULTS = "nimbus-desktop-defaults";
const ENABLED_PREF = "messaging-system.rsexperimentloader.enabled";
const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled";
@ -62,117 +60,6 @@ XPCOMUtils.defineLazyPreferenceGetter(
false
);
/**
* Responsible for pre-fetching remotely defined configurations from
* Remote Settings.
*/
const RemoteDefaultsLoader = {
async syncRemoteDefaults(reason) {
log.debug("Fetching remote defaults for NimbusFeatures.");
try {
await this._onUpdatesReady(
await this._remoteSettingsClient.get(),
reason
);
} catch (e) {
Cu.reportError(e);
}
log.debug("Finished fetching remote defaults.");
},
async _onUpdatesReady(remoteDefaults = [], reason = "unknown") {
const matches = [];
const existingConfigIds = ExperimentManager.store.getAllExistingRemoteConfigIds();
if (remoteDefaults.length) {
await ExperimentManager.store.ready();
// Iterate over remote defaults: at most 1 per feature
for (let remoteDefault of remoteDefaults) {
if (!remoteDefault.configurations) {
continue;
}
// Iterate over feature configurations and apply first which matches targeting
for (let configuration of remoteDefault.configurations) {
let result;
if (
configuration.bucketConfig &&
!(await ExperimentManager.isInBucketAllocation(
configuration.bucketConfig
))
) {
log.debug(
"Remote Configuration was not applied because of the bucket sampling"
);
continue;
}
try {
result = await RemoteSettingsExperimentLoader.evaluateJexl(
configuration.targeting,
{
activeRemoteDefaults: existingConfigIds,
source: configuration.slug,
}
);
} catch (e) {
Cu.reportError(e);
}
if (result) {
log.debug(
`Setting remote defaults for feature: ${
remoteDefault.id
}: ${JSON.stringify(configuration)}`
);
matches.push(remoteDefault.id);
const existing = ExperimentManager.store.getRemoteConfig(
remoteDefault.id
);
ExperimentManager.store.updateRemoteConfigs(
remoteDefault.id,
configuration
);
// Update Telemetry environment. Note that we should always update during initialization,
// but after that we don't need to.
if (
reason === "init" ||
!existing ||
existing.slug !== configuration.slug
) {
ExperimentManager.setRemoteDefaultActive(
remoteDefault.id,
configuration.slug
);
}
break;
} else {
log.debug(
`Remote default config ${configuration.slug} for ${remoteDefault.id} did not match due to targeting`
);
}
}
}
}
// Remove any pre-existing configurations that weren't found
for (const id of existingConfigIds) {
if (!matches.includes(id)) {
ExperimentManager.setRemoteDefaultInactive(id);
}
}
// Do final cleanup
ExperimentManager.store.finalizeRemoteConfigs(matches);
},
};
XPCOMUtils.defineLazyGetter(RemoteDefaultsLoader, "_remoteSettingsClient", () =>
RemoteSettings(COLLECTION_REMOTE_DEFAULTS)
);
class _RemoteSettingsExperimentLoader {
constructor() {
// Has the timer been set?
@ -214,8 +101,6 @@ class _RemoteSettingsExperimentLoader {
async init() {
if (this._initialized || !this.enabled || !this.studiesEnabled) {
// Resolves any Promise waiting for Remote Settings data
ExperimentManager.store.finalizeRemoteConfigs([]);
return;
}
@ -223,10 +108,7 @@ class _RemoteSettingsExperimentLoader {
CleanupManager.addCleanupHandler(() => this.uninit());
this._initialized = true;
await Promise.all([
this.updateRecipes(),
RemoteDefaultsLoader.syncRemoteDefaults("init"),
]);
await this.updateRecipes();
}
uninit() {
@ -239,13 +121,9 @@ class _RemoteSettingsExperimentLoader {
}
async evaluateJexl(jexlString, customContext) {
if (
customContext &&
!customContext.experiment &&
!customContext.activeRemoteDefaults
) {
if (customContext && !customContext.experiment) {
throw new Error(
"Expected an .experiment or .activeRemoteDefaults property in second param of this function"
"Expected an .experiment property in second param of this function"
);
}
@ -324,9 +202,10 @@ class _RemoteSettingsExperimentLoader {
let recipeMismatches = [];
if (recipes && !loadingError) {
for (const r of recipes) {
let type = r.isRollout ? "rollout" : "experiment";
if (await this.checkTargeting(r)) {
matches++;
log.debug(`${r.id} matched`);
log.debug(`[${type}] ${r.id} matched`);
await this.manager.onRecipe(r, "rs-loader");
} else {
log.debug(`${r.id} did not match due to targeting`);
@ -409,10 +288,7 @@ class _RemoteSettingsExperimentLoader {
// The callbacks will be called soon after the timer is registered
timerManager.registerTimer(
TIMER_NAME,
() => {
this.updateRecipes("timer");
RemoteDefaultsLoader.syncRemoteDefaults("timer");
},
() => this.updateRecipes("timer"),
this.intervalInSeconds
);
log.debug("Registered update timer");

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

@ -39,10 +39,6 @@ class SharedDataMap extends EventEmitter {
this._data = null;
if (this.isParent) {
// We have an in memory store and a file backed store.
// We use the `nonPersistentStore` for remote feature defaults and
// `store` for experiment recipes
this._nonPersistentStore = null;
// Lazy-load JSON file that backs Storage instances.
XPCOMUtils.defineLazyGetter(this, "_store", () => {
let path = options.path;
@ -73,7 +69,6 @@ class SharedDataMap extends EventEmitter {
try {
await this._store.load();
this._data = this._store.data;
this._nonPersistentStore = {};
this._syncToChildren({ flush: true });
this._checkIfReady();
} catch (e) {
@ -101,10 +96,6 @@ class SharedDataMap extends EventEmitter {
let entry = this._data[key];
if (!entry && this._nonPersistentStore) {
return this._nonPersistentStore[key];
}
return entry;
}
@ -141,22 +132,6 @@ class SharedDataMap extends EventEmitter {
this._store.saveSoon();
}
setNonPersistent(key, value) {
if (!this.isParent) {
throw new Error(
"Setting values from within a content process is not allowed"
);
}
this._nonPersistentStore[key] = value;
this._syncToChildren();
this._notifyUpdate();
}
hasRemoteDefaultsReady() {
return this._nonPersistentStore?.__REMOTE_DEFAULTS;
}
// Only used in tests
_deleteForTests(key) {
if (!this.isParent) {
@ -166,22 +141,10 @@ class SharedDataMap extends EventEmitter {
}
if (this.has(key)) {
delete this._store.data[key];
this._store.saveSoon();
this._syncToChildren();
this._notifyUpdate();
}
if (this._nonPersistentStore) {
delete this._nonPersistentStore.__REMOTE_DEFAULTS?.[key];
if (
!Object.keys(this._nonPersistentStore?.__REMOTE_DEFAULTS || {}).length
) {
// If we are doing test cleanup and we removed all remote rollout entries
// we want to additionally remove the __REMOTE_DEFAULTS key because
// we use it to determine if a remote sync event happened (`.ready()`)
this._nonPersistentStore = {};
}
}
this._store.saveSoon();
this._syncToChildren();
this._notifyUpdate();
}
has(key) {
@ -196,18 +159,11 @@ class SharedDataMap extends EventEmitter {
for (let key of Object.keys(this._data || {})) {
this.emit(`${process}-store-update:${key}`, this._data[key]);
}
for (let key of Object.keys(this._nonPersistentStore || {})) {
this.emit(
`${process}-store-update:${key}`,
this._nonPersistentStore[key]
);
}
}
_syncToChildren({ flush = false } = {}) {
Services.ppmm.sharedData.set(this.sharedDataKey, {
...this._data,
...this._nonPersistentStore,
});
if (flush) {
Services.ppmm.sharedData.flush();

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

@ -13,13 +13,17 @@
"properties": {
"features": {
"type": "array",
"items": {
"items": {
"type": "object",
"properties": {
"featureId": {
"type": "string",
"description": "The identifier for the feature flag"
},
"isEarlyStartup": {
"type": "boolean",
"description": "Early startup features are stored to prefs."
},
"value": {
"anyOf": [
{
@ -48,6 +52,10 @@
"type": "boolean",
"description": "Experiment status"
},
"isRollout": {
"type": "boolean",
"description": "If this is true, the enrollment is a rollout. If it is missing or false, it is an experiment."
},
"enrollmentId": {
"type": "string",
"description": "Unique identifier used in telemetry"

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

@ -131,9 +131,9 @@ const ExperimentTestUtils = {
async validateRollouts(rollout) {
const schema = (
await fetchSchema(
"resource://testing-common/ExperimentFeatureRemote.schema.json"
"resource://testing-common/NimbusEnrollment.schema.json"
)
).RemoteFeatureConfiguration;
).NimbusExperiment;
return this._validator(
schema,
@ -147,10 +147,10 @@ const ExperimentFakes = {
manager(store) {
let sandbox = sinon.createSandbox();
let manager = new _ExperimentManager({ store: store || this.store() });
// We want calls to `store.addExperiment` to implicitly validate the
// We want calls to `store.addEnrollment` to implicitly validate the
// enrollment before saving to store
let origAddExperiment = manager.store.addExperiment.bind(manager.store);
sandbox.stub(manager.store, "addExperiment").callsFake(async enrollment => {
let origAddExperiment = manager.store.addEnrollment.bind(manager.store);
sandbox.stub(manager.store, "addEnrollment").callsFake(async enrollment => {
await ExperimentTestUtils.validateEnrollment(enrollment);
return origAddExperiment(enrollment);
});
@ -167,22 +167,42 @@ const ExperimentFakes = {
return new Promise(resolve => ExperimentAPI.on("update", options, resolve));
},
async remoteDefaultsHelper({
feature,
store = ExperimentManager.store,
configuration,
}) {
if (!store._isReady) {
throw new Error("Store not ready, need to `await ExperimentAPI.ready()`");
async enrollWithRollout(
featureConfig,
{ manager = ExperimentManager, source } = {}
) {
await manager.store.init();
const rollout = this.rollout(`${featureConfig.featureId}-rollout`, {
branch: {
slug: `${featureConfig.featureId}-rollout-branch`,
features: [featureConfig],
},
});
if (source) {
rollout.source = source;
}
await ExperimentTestUtils.validateRollouts(configuration);
await ExperimentTestUtils.validateRollouts(rollout);
// After storing the remote configuration to store and updating the feature
// we want to flush so that NimbusFeature usage in content process also
// receives the update
store.updateRemoteConfigs(feature.featureId, configuration);
await feature.ready();
store._syncToChildren({ flush: true });
await manager.store.addEnrollment(rollout);
manager.store._syncToChildren({ flush: true });
let unenrollCompleted = slug =>
new Promise(resolve =>
manager.store.on(`update:${slug}`, (event, enrollment) => {
if (enrollment.slug === rollout.slug && !enrollment.active) {
manager.store._deleteForTests(rollout.slug);
resolve();
}
})
);
return () => {
let promise = unenrollCompleted(rollout.slug);
manager.unenroll(rollout.slug, "cleanup");
return promise;
};
},
async enrollWithFeatureConfig(
featureConfig,
@ -306,6 +326,33 @@ const ExperimentFakes = {
...props,
};
},
rollout(slug, props = {}) {
return {
slug,
active: true,
enrollmentId: NormandyUtils.generateUuid(),
isRollout: true,
branch: {
slug: "treatment",
features: [
{
featureId: "test-feature",
value: { title: "hello", enabled: true },
},
],
...props,
},
source: "NimbusTestUtils",
isEnrollmentPaused: true,
experimentType: "rollout",
userFacingName: "NimbusTestUtils",
userFacingDescription: "NimbusTestUtils",
featureIds: (props?.branch?.features || props?.features)?.map(
f => f.featureId
) || ["test-feature"],
...props,
};
},
recipe(slug = NormandyUtils.generateUuid(), props = {}) {
return {
// This field is required for populating remote settings

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

@ -34,7 +34,7 @@ add_task(async function test_throws_if_no_experiment_in_context() {
customThing: 1,
source: "test_throws_if_no_experiment_in_context",
}),
/Expected an .experiment or .activeRemoteDefaults/,
/Expected an .experiment/,
"should throw if experiment is not passed to the custom context"
);
});

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

@ -118,3 +118,42 @@ add_task(async function test_experiment_expose_Telemetry() {
await cleanup();
});
add_task(async function test_rollout_expose_Telemetry() {
const featureManifest = {
description: "Test feature",
exposureDescription: "Used in tests",
};
const cleanup = await ExperimentFakes.enrollWithRollout({
featureId: "test-feature",
value: { enabled: false },
});
let rollout = ExperimentAPI.getRolloutMetaData({
featureId: "test-feature",
});
Assert.ok(rollout.slug, "Found enrolled experiment");
const feature = new ExperimentFeature("test-feature", featureManifest);
Services.telemetry.clearEvents();
feature.recordExposureEvent();
TelemetryTestUtils.assertEvents(
[
{
method: "expose",
object: TELEMETRY_OBJECT,
value: rollout.slug,
extra: {
branchSlug: rollout.branch.slug,
featureId: feature.featureId,
},
},
],
EVENT_FILTER
);
await cleanup();
});

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

@ -6,10 +6,7 @@
const { RemoteSettings } = ChromeUtils.import(
"resource://services-settings/remote-settings.js"
);
const {
RemoteDefaultsLoader,
RemoteSettingsExperimentLoader,
} = ChromeUtils.import(
const { RemoteSettingsExperimentLoader } = ChromeUtils.import(
"resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm"
);
const { BrowserTestUtils } = ChromeUtils.import(

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

@ -14,10 +14,7 @@ const {
const { ExperimentManager } = ChromeUtils.import(
"resource://nimbus/lib/ExperimentManager.jsm"
);
const {
RemoteDefaultsLoader,
RemoteSettingsExperimentLoader,
} = ChromeUtils.import(
const { RemoteSettingsExperimentLoader } = ChromeUtils.import(
"resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm"
);
const { BrowserTestUtils } = ChromeUtils.import(
@ -51,87 +48,56 @@ const BAR_FAKE_FEATURE_MANIFEST = {
},
};
const REMOTE_CONFIGURATION_FOO = {
id: "foo",
description: "foo remote feature value",
configurations: [
const ENSURE_ENROLLMENT = {
targeting: "true",
bucketConfig: {
namespace: "nimbus-test-utils",
randomizationUnit: "normandy_id",
start: 0,
count: 1000,
total: 1000,
},
};
const REMOTE_CONFIGURATION_FOO = ExperimentFakes.recipe("foo-rollout", {
isRollout: true,
branches: [
{
slug: "a",
variables: { remoteValue: 24, enabled: false },
targeting: "false",
isEarlyStartup: true,
bucketConfig: {
namespace: "nimbus-test-utils",
randomizationUnit: "normandy_id",
start: 0,
count: 1000,
total: 1000,
},
},
{
slug: "b",
variables: { remoteValue: 42, enabled: true },
targeting: "true",
isEarlyStartup: true,
bucketConfig: {
namespace: "nimbus-test-utils",
randomizationUnit: "normandy_id",
start: 0,
count: 1000,
total: 1000,
},
slug: "foo-rollout-branch",
features: [
{
featureId: "foo",
enabled: true,
isEarlyStartup: true,
value: { remoteValue: 42 },
},
],
},
],
};
const REMOTE_CONFIGURATION_BAR = {
id: "bar",
description: "bar remote feature value",
configurations: [
...ENSURE_ENROLLMENT,
});
const REMOTE_CONFIGURATION_BAR = ExperimentFakes.recipe("bar-rollout", {
isRollout: true,
branches: [
{
slug: "a",
variables: { remoteValue: 1, enabled: false },
targeting: "false",
isEarlyStartup: true,
bucketConfig: {
namespace: "nimbus-test-utils",
randomizationUnit: "normandy_id",
start: 0,
count: 1000,
total: 1000,
},
},
{
slug: "b",
variables: { remoteValue: 3, enabled: true },
targeting: "true",
isEarlyStartup: true,
bucketConfig: {
namespace: "nimbus-test-utils",
randomizationUnit: "normandy_id",
start: 0,
count: 1000,
total: 1000,
},
},
{
slug: "c",
variables: { remoteValue: 2, enabled: false },
targeting: "false",
isEarlyStartup: true,
bucketConfig: {
namespace: "nimbus-test-utils",
randomizationUnit: "normandy_id",
start: 0,
count: 1000,
total: 1000,
},
slug: "bar-rollout-branch",
features: [
{
featureId: "bar",
enabled: true,
isEarlyStartup: true,
value: { remoteValue: 3 },
},
],
},
],
};
...ENSURE_ENROLLMENT,
});
const SYNC_DEFAULTS_PREF_BRANCH = "nimbus.syncdefaultsstore.";
async function setup(configuration) {
const client = RemoteSettings("nimbus-desktop-defaults");
const client = RemoteSettings("nimbus-desktop-experiments");
await client.db.importChanges(
{},
42,
@ -152,8 +118,6 @@ add_task(async function test_remote_fetch_and_ready() {
const sandbox = sinon.createSandbox();
const fooInstance = new ExperimentFeature("foo", FOO_FAKE_FEATURE_MANIFEST);
const barInstance = new ExperimentFeature("bar", BAR_FAKE_FEATURE_MANIFEST);
let stub = sandbox.stub();
let spy = sandbox.spy(ExperimentAPI._store, "finalizeRemoteConfigs");
const setExperimentActiveStub = sandbox.stub(
TelemetryEnvironment,
"setExperimentActive"
@ -162,10 +126,6 @@ add_task(async function test_remote_fetch_and_ready() {
TelemetryEnvironment,
"setExperimentInactive"
);
ExperimentAPI._store._deleteForTests("foo");
ExperimentAPI._store._deleteForTests("bar");
fooInstance.onUpdate(stub);
Assert.equal(
fooInstance.getVariable("remoteValue"),
@ -173,39 +133,38 @@ add_task(async function test_remote_fetch_and_ready() {
"This prop does not exist before we sync"
);
// Create to promises that get resolved when the features update
// with the remote setting rollouts
let fooUpdate = new Promise(resolve => fooInstance.onUpdate(resolve));
let barUpdate = new Promise(resolve => barInstance.onUpdate(resolve));
await ExperimentAPI.ready();
let rsClient = await setup();
await RemoteDefaultsLoader.syncRemoteDefaults();
Assert.equal(
spy.callCount,
1,
"Called finalize after processing remote configs"
// Fake being initialized so we can update recipes
// we don't need to start any timers
RemoteSettingsExperimentLoader._initialized = true;
await RemoteSettingsExperimentLoader.updateRecipes(
"browser_rsel_remote_defaults"
);
// We need to await here because remote configurations are processed
// async to evaluate targeting
await Promise.all([fooInstance.ready(), barInstance.ready()]);
await Promise.all([fooUpdate, barUpdate]);
Assert.ok(fooInstance.isEnabled(), "Enabled by remote defaults");
Assert.equal(
fooInstance.getVariable("remoteValue"),
REMOTE_CONFIGURATION_FOO.configurations[1].variables.remoteValue,
REMOTE_CONFIGURATION_FOO.branches[0].features[0].value.remoteValue,
"`foo` feature is set by remote defaults"
);
Assert.equal(
barInstance.getVariable("remoteValue"),
REMOTE_CONFIGURATION_BAR.configurations[1].variables.remoteValue,
REMOTE_CONFIGURATION_BAR.branches[0].features[0].value.remoteValue,
"`bar` feature is set by remote defaults"
);
Assert.equal(stub.callCount, 1, "Called by RS sync");
Assert.equal(
stub.firstCall.args[1],
"remote-defaults-update",
"We receive events on remote defaults updates"
);
Assert.ok(
Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar`),
"Pref cache is set"
@ -220,38 +179,43 @@ add_task(async function test_remote_fetch_and_ready() {
Assert.ok(
setExperimentActiveStub.calledWith(
"default-foo",
REMOTE_CONFIGURATION_FOO.configurations[1].slug,
REMOTE_CONFIGURATION_FOO.slug,
REMOTE_CONFIGURATION_FOO.branches[0].slug,
{
type: "nimbus-default",
enrollmentId: "__NO_ENROLLMENT_ID__",
type: "nimbus-rollout",
enrollmentId: sinon.match.string,
}
),
"should call setExperimentActive with `foo` feature"
);
Assert.ok(
setExperimentActiveStub.calledWith(
"default-bar",
REMOTE_CONFIGURATION_BAR.configurations[1].slug,
REMOTE_CONFIGURATION_BAR.slug,
REMOTE_CONFIGURATION_BAR.branches[0].slug,
{
type: "nimbus-default",
enrollmentId: "__NO_ENROLLMENT_ID__",
type: "nimbus-rollout",
enrollmentId: sinon.match.string,
}
),
"should call setExperimentActive with `bar` feature"
);
Assert.equal(fooInstance.getVariable("remoteValue"), 42, "Has rollout value");
Assert.equal(barInstance.getVariable("remoteValue"), 3, "Has rollout value");
// Clear RS db and load again. No configurations so should clear the cache.
await rsClient.db.clear();
await RemoteDefaultsLoader.syncRemoteDefaults();
await RemoteSettingsExperimentLoader.updateRecipes(
"browser_rsel_remote_defaults"
);
Assert.equal(spy.callCount, 2, "Called a second time by syncRemoteDefaults");
Assert.ok(stub.calledTwice, "Second update is from the removal");
Assert.equal(
stub.secondCall.args[1],
"remote-defaults-update",
"We receive events when the remote configuration is removed"
Assert.ok(
!fooInstance.getVariable("remoteValue"),
"foo-rollout should be removed"
);
Assert.ok(
!barInstance.getVariable("remoteValue"),
"bar-rollout should be removed"
);
// Check if we sent active experiment data for defaults
@ -262,11 +226,11 @@ add_task(async function test_remote_fetch_and_ready() {
);
Assert.ok(
setExperimentInactiveStub.calledWith("default-foo"),
setExperimentInactiveStub.calledWith(REMOTE_CONFIGURATION_FOO.slug),
"should call setExperimentInactive with `foo` feature"
);
Assert.ok(
setExperimentInactiveStub.calledWith("default-bar"),
setExperimentInactiveStub.calledWith(REMOTE_CONFIGURATION_BAR.slug),
"should call setExperimentInactive with `bar` feature"
);
@ -276,17 +240,18 @@ add_task(async function test_remote_fetch_and_ready() {
);
Assert.ok(!barInstance.getVariable("remoteValue"), "Should be missing");
fooInstance.off(stub);
ExperimentAPI._store._deleteForTests("foo");
ExperimentAPI._store._deleteForTests("bar");
ExperimentAPI._store._deleteForTests(REMOTE_CONFIGURATION_FOO.slug);
ExperimentAPI._store._deleteForTests(REMOTE_CONFIGURATION_BAR.slug);
sandbox.restore();
});
add_task(async function test_remote_fetch_on_updateRecipes() {
let sandbox = sinon.createSandbox();
let syncRemoteDefaultsStub = sandbox.stub(
RemoteDefaultsLoader,
"syncRemoteDefaults"
let updateRecipesStub = sandbox.stub(
RemoteSettingsExperimentLoader,
"updateRecipes"
);
// Work around the pref change callback that would trigger `setTimer`
sandbox.replaceGetter(
@ -305,16 +270,12 @@ add_task(async function test_remote_fetch_on_updateRecipes() {
RemoteSettingsExperimentLoader.setTimer();
await BrowserTestUtils.waitForCondition(
() => syncRemoteDefaultsStub.called,
() => updateRecipesStub.called,
"Wait for timer to call"
);
Assert.ok(syncRemoteDefaultsStub.calledOnce, "Timer calls function");
Assert.equal(
syncRemoteDefaultsStub.firstCall.args[0],
"timer",
"Called by timer"
);
Assert.ok(updateRecipesStub.calledOnce, "Timer calls function");
Assert.equal(updateRecipesStub.firstCall.args[0], "timer", "Called by timer");
sandbox.restore();
// This will un-register the timer
RemoteSettingsExperimentLoader._initialized = true;
@ -324,113 +285,60 @@ add_task(async function test_remote_fetch_on_updateRecipes() {
);
});
// Test that awaiting `feature.ready()` resolves even when there is no remote
// data
add_task(async function test_remote_fetch_no_data_syncRemoteBefore() {
const sandbox = sinon.createSandbox();
const fooInstance = new ExperimentFeature("foo", FOO_FAKE_FEATURE_MANIFEST);
const barInstance = new ExperimentFeature("bar", {
bar: { description: "mochitests" },
});
const stub = sandbox.stub();
const spy = sandbox.spy(ExperimentAPI._store, "finalizeRemoteConfigs");
ExperimentAPI._store.on("remote-defaults-finalized", stub);
await setup();
await RemoteDefaultsLoader.syncRemoteDefaults();
// featureFoo will also resolve when the remote defaults cycle finishes
await Promise.all([fooInstance.ready(), barInstance.ready()]);
Assert.ok(spy.calledOnce, "Called finalizeRemoteConfigs");
Assert.deepEqual(spy.firstCall.args[0], ["bar", "foo"]);
Assert.equal(stub.callCount, 1, "Notified all features");
ExperimentAPI._store.off("remote-defaults-finalized", stub);
ExperimentAPI._store._deleteForTests("foo");
ExperimentAPI._store._deleteForTests("bar");
sandbox.restore();
});
// Test that awaiting `feature.ready()` resolves even when there is no remote
// data
add_task(async function test_remote_fetch_no_data_noWaitRemoteLoad() {
const fooInstance = new ExperimentFeature("foo", FOO_FAKE_FEATURE_MANIFEST);
const barInstance = new ExperimentFeature("bar", {
bar: { description: "mochitests" },
});
const stub = sinon.stub();
ExperimentAPI._store.on("remote-defaults-finalized", stub);
await setup([]);
// Don't wait to load remote defaults; make sure there is no blocking issue
// with the `ready` call
RemoteDefaultsLoader.syncRemoteDefaults();
// featureFoo will also resolve when the remote defaults cycle finishes
await Promise.all([fooInstance.ready(), barInstance.ready()]);
Assert.equal(stub.callCount, 1, "Notified all features");
ExperimentAPI._store.off("remote-defaults-finalized", stub);
ExperimentAPI._store._deleteForTests("bar");
ExperimentAPI._store._deleteForTests("foo");
});
add_task(async function test_remote_ready_from_experiment() {
const featureFoo = new ExperimentFeature("foo", {
foo: { description: "mochitests" },
});
await ExperimentAPI.ready();
let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
enabled: true,
featureId: "foo",
value: null,
});
// featureFoo will also resolve when the remote defaults cycle finishes
await featureFoo.ready();
Assert.ok(
true,
"We resolved the remote defaults ready by enrolling in an experiment that targets this feature"
);
await doExperimentCleanup();
});
add_task(async function test_finalizeRemoteConfigs_cleanup() {
const SYNC_DEFAULTS_PREF_BRANCH = "nimbus.syncdefaultsstore.";
const featureFoo = new ExperimentFeature("foo", {
foo: { description: "mochitests" },
});
const featureBar = new ExperimentFeature("bar", {
foo: { description: "mochitests" },
});
let fooCleanup = await ExperimentFakes.enrollWithRollout(
{
featureId: "foo",
enabled: true,
isEarlyStartup: true,
value: { foo: true },
},
{
source: "rs-loader",
}
);
await ExperimentFakes.enrollWithRollout(
{
featureId: "bar",
enabled: true,
isEarlyStartup: true,
value: { bar: true },
},
{
source: "rs-loader",
}
);
let stubFoo = sinon.stub();
let stubBar = sinon.stub();
featureFoo.onUpdate(stubFoo);
featureBar.onUpdate(stubBar);
let cleanupPromise = new Promise(resolve => featureBar.onUpdate(resolve));
Services.prefs.setStringPref(
`${SYNC_DEFAULTS_PREF_BRANCH}foo`,
JSON.stringify({ foo: true })
JSON.stringify({ foo: true, branch: { feature: { featureId: "foo" } } })
);
Services.prefs.setStringPref(
`${SYNC_DEFAULTS_PREF_BRANCH}bar`,
JSON.stringify({ bar: true })
JSON.stringify({ bar: true, branch: { feature: { featureId: "bar" } } })
);
ExperimentAPI._store.finalizeRemoteConfigs(["foo"]);
await setup([REMOTE_CONFIGURATION_FOO]);
RemoteSettingsExperimentLoader._initialized = true;
await RemoteSettingsExperimentLoader.updateRecipes();
await cleanupPromise;
Assert.ok(stubFoo.notCalled, "Not called, feature seen in session");
Assert.ok(stubBar.called, "Called, feature not seen in session");
Assert.ok(
stubFoo.notCalled,
"Not called, not enrolling in rollout feature already exists"
);
Assert.ok(stubBar.called, "Called because no recipe is seen, cleanup");
Assert.ok(
Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}foo`),
"Pref is not cleared"
@ -439,52 +347,31 @@ add_task(async function test_finalizeRemoteConfigs_cleanup() {
!Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar`, ""),
"Pref was cleared"
);
// cleanup
Services.prefs.clearUserPref(`${SYNC_DEFAULTS_PREF_BRANCH}foo`);
});
add_task(async function remote_defaults_resolve_telemetry_off() {
await SpecialPowers.pushPrefEnv({
set: [["app.shield.optoutstudies.enabled", false]],
});
let stub = sinon.stub();
ExperimentAPI._store.on("remote-defaults-finalized", stub);
const feature = new ExperimentFeature("foo", {
foo: { description: "test" },
});
let promise = feature.ready();
RemoteSettingsExperimentLoader.init();
await promise;
Assert.equal(stub.callCount, 1, "init returns early and resolves the await");
});
add_task(async function remote_defaults_resolve_timeout() {
const feature = new ExperimentFeature("foo", {
foo: { description: "test" },
});
await feature.ready(1);
Assert.ok(true, "Resolves waitForRemote");
await fooCleanup();
// This will also remove the inactive recipe from the store
// the previous update (from recipe not seen code path)
// only sets the recipe as inactive
ExperimentAPI._store._deleteForTests("bar-rollout");
ExperimentAPI._store._deleteForTests("foo-rollout");
});
// If the remote config data returned from the store is not modified
// this test should not throw
add_task(async function remote_defaults_no_mutation() {
let sandbox = sinon.createSandbox();
sandbox
.stub(ExperimentAPI._store, "getRemoteConfig")
.returns(
Cu.cloneInto(
{ targeting: "true", variables: { remoteStub: true } },
{},
{ deepFreeze: true }
)
);
sandbox.stub(ExperimentAPI._store, "getRolloutForFeature").returns(
Cu.cloneInto(
{
featureIds: ["foo"],
branch: {
features: [{ featureId: "foo", value: { remoteStub: true } }],
},
},
{},
{ deepFreeze: true }
)
);
let fooInstance = new ExperimentFeature("foo", FOO_FAKE_FEATURE_MANIFEST);
let config = fooInstance.getAllVariables();
@ -494,68 +381,6 @@ add_task(async function remote_defaults_no_mutation() {
sandbox.restore();
});
add_task(async function remote_defaults_active_experiments_check() {
let barFeature = new ExperimentFeature("bar", {
description: "mochitest",
variables: { enabled: { type: "boolean" } },
});
let experimentOnlyRemoteDefault = {
id: "bar",
description: "if we're in the foo experiment bar should be off",
configurations: [
{
slug: "a",
variables: { enabled: false },
targeting: "'mochitest-active-foo' in activeExperiments",
},
{
slug: "b",
variables: { enabled: true },
targeting: "true",
},
],
};
await setup([experimentOnlyRemoteDefault]);
await RemoteDefaultsLoader.syncRemoteDefaults("mochitest");
await barFeature.ready();
Assert.ok(barFeature.isEnabled(), "First it's enabled");
let {
enrollmentPromise,
doExperimentCleanup,
} = ExperimentFakes.enrollmentHelper(
ExperimentFakes.recipe("mochitest-active-foo", {
branches: [
{
slug: "mochitest-active-foo",
features: [
{
enabled: true,
featureId: "foo",
value: null,
},
],
},
],
active: true,
})
);
await enrollmentPromise;
let featureUpdate = new Promise(resolve => barFeature.onUpdate(resolve));
await RemoteDefaultsLoader.syncRemoteDefaults("mochitests");
await featureUpdate;
Assert.ok(
!barFeature.isEnabled(),
"We've enrolled in an experiment which makes us match on the first remote default that disables the feature"
);
await doExperimentCleanup();
});
add_task(async function remote_defaults_active_remote_defaults() {
ExperimentAPI._store._deleteForTests("foo");
ExperimentAPI._store._deleteForTests("bar");
@ -567,40 +392,55 @@ add_task(async function remote_defaults_active_remote_defaults() {
description: "mochitest",
variables: { enabled: { type: "boolean" } },
});
let remoteDefaults = [
{
id: "bar",
description: "will enroll first try",
configurations: [
{
slug: "a",
variables: { enabled: true },
targeting: "true",
},
],
},
{
id: "foo",
description: "will enroll second try after bar",
configurations: [
{
slug: "b",
variables: { enabled: true },
targeting: "'bar' in activeRemoteDefaults",
},
],
},
];
let rollout1 = ExperimentFakes.recipe("bar", {
branches: [
{
slug: "bar-rollout-branch",
ratio: 1,
features: [
{
featureId: "bar",
value: { enabled: true },
},
],
},
],
isRollout: true,
...ENSURE_ENROLLMENT,
targeting: "true",
});
await setup(remoteDefaults);
await RemoteDefaultsLoader.syncRemoteDefaults("mochitest");
await barFeature.ready();
let rollout2 = ExperimentFakes.recipe("foo", {
branches: [
{
slug: "foo-rollout-branch",
ratio: 1,
features: [
{
featureId: "foo",
value: { enabled: true },
},
],
},
],
isRollout: true,
...ENSURE_ENROLLMENT,
targeting: "'bar' in activeRollouts",
});
// Order is important, rollout2 won't match at first
await setup([rollout2, rollout1]);
let updatePromise = new Promise(resolve => barFeature.onUpdate(resolve));
RemoteSettingsExperimentLoader._initialized = true;
await RemoteSettingsExperimentLoader.updateRecipes("mochitest");
await updatePromise;
Assert.ok(barFeature.isEnabled(), "Enabled on first sync");
Assert.ok(!fooFeature.isEnabled(), "Targeting doesn't match");
let featureUpdate = new Promise(resolve => fooFeature.onUpdate(resolve));
await RemoteDefaultsLoader.syncRemoteDefaults("mochitest");
await RemoteSettingsExperimentLoader.updateRecipes("mochitest");
await featureUpdate;
Assert.ok(fooFeature.isEnabled(), "Targeting should match");
@ -608,136 +448,38 @@ add_task(async function remote_defaults_active_remote_defaults() {
ExperimentAPI._store._deleteForTests("bar");
});
add_task(async function test_remote_defaults_bucketConfig() {
const sandbox = sinon.createSandbox();
let finalizeRemoteConfigsSpy = sandbox.spy(
ExperimentAPI._store,
"finalizeRemoteConfigs"
);
let isInBucketAllocationStub = sandbox
.stub(ExperimentManager, "isInBucketAllocation")
.resolves(false);
let evaluateJexlStub = sandbox
.stub(RemoteSettingsExperimentLoader, "evaluateJexl")
.resolves(true);
let rsClient = await setup();
await RemoteDefaultsLoader.syncRemoteDefaults("mochitest");
Assert.equal(
isInBucketAllocationStub.callCount,
5,
"Bucket allocation is checked"
);
Assert.equal(
evaluateJexlStub.callCount,
0,
"We skip targeting if bucket allocation fails"
);
Assert.equal(
finalizeRemoteConfigsSpy.called,
true,
"Finally no configs match"
);
Assert.deepEqual(
finalizeRemoteConfigsSpy.firstCall.args[0],
[],
"No configs matched because of bucket allocation"
);
sandbox.restore();
await rsClient.db.clear();
});
add_task(async function test_remote_defaults_no_bucketConfig() {
const sandbox = sinon.createSandbox();
const remoteConfigNoBucket = {
id: "aboutwelcome",
description: "about:welcome",
configurations: [
{
slug: "a",
variables: { remoteValue: 24, enabled: false },
targeting: "false",
},
{
slug: "b",
variables: { remoteValue: 42, enabled: true },
targeting: "true",
},
],
};
let finalizeRemoteConfigsStub = sandbox.stub(
ExperimentAPI._store,
"finalizeRemoteConfigs"
);
let isInBucketAllocationStub = sandbox
.stub(ExperimentManager, "isInBucketAllocation")
.resolves(false);
let evaluateJexlStub = sandbox.spy(
RemoteSettingsExperimentLoader,
"evaluateJexl"
);
let rsClient = await setup([remoteConfigNoBucket]);
await RemoteDefaultsLoader.syncRemoteDefaults("mochitest");
Assert.ok(isInBucketAllocationStub.notCalled, "No bucket config to call");
Assert.equal(evaluateJexlStub.callCount, 2, "Called for two remote configs");
Assert.deepEqual(
finalizeRemoteConfigsStub.firstCall.args[0],
["aboutwelcome"],
"Match the config with targeting set to `true`"
);
sandbox.restore();
await rsClient.db.clear();
});
add_task(async function remote_defaults_variables_storage() {
let barFeature = new ExperimentFeature("bar", {
bar: {
description: "mochitest",
variables: {
storage: {
type: "int",
},
object: {
type: "json",
},
string: {
type: "string",
},
bool: {
type: "boolean",
},
description: "mochitest",
variables: {
storage: {
type: "int",
},
object: {
type: "json",
},
string: {
type: "string",
},
bool: {
type: "boolean",
},
},
});
let remoteDefaults = [
{
id: "bar",
description: "test pref storage and types",
configurations: [
{
slug: "a",
isEarlyStartup: true,
variables: {
storage: 42,
object: { foo: "foo" },
string: "string",
bool: true,
enabled: true,
},
targeting: "true",
},
],
},
];
let rolloutValue = {
storage: 42,
object: { foo: "foo" },
string: "string",
bool: true,
enabled: true,
};
await setup(remoteDefaults);
await RemoteDefaultsLoader.syncRemoteDefaults("mochitest");
await barFeature.ready();
let doCleanup = await ExperimentFakes.enrollWithRollout({
featureId: "bar",
enabled: true,
isEarlyStartup: true,
value: rolloutValue,
});
Assert.ok(
Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar`, ""),
@ -753,16 +495,19 @@ add_task(async function remote_defaults_variables_storage() {
"Stores variable in correct type"
);
Assert.deepEqual(
barFeature.getRemoteConfig().variables,
remoteDefaults[0].configurations[0].variables,
barFeature.getAllVariables(),
rolloutValue,
"Test types are returned correctly"
);
ExperimentAPI._store._deleteForTests("bar");
await doCleanup();
Assert.equal(
Services.prefs.getIntPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar.storage`, -1),
-1,
"Variable pref is cleared"
);
Assert.ok(!barFeature.getVariable("string"), "Variable is no longer defined");
ExperimentAPI._store._deleteForTests("bar");
ExperimentAPI._store._deleteForTests("bar-rollout");
});

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

@ -12,8 +12,6 @@ XPCOMUtils.defineLazyModuleGetters(this, {
ExperimentManager: "resource://nimbus/lib/ExperimentManager.jsm",
Ajv: "resource://testing-common/ajv-6.12.6.js",
ExperimentTestUtils: "resource://testing-common/NimbusTestUtils.jsm",
RemoteDefaultsLoader:
"resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm",
ExperimentFakes: "resource://testing-common/NimbusTestUtils.jsm",
});
@ -25,33 +23,15 @@ add_task(function setup() {
* the schema and no records have missing (or extra) properties while in tests
*/
let origAddExperiment = ExperimentManager.store.addExperiment.bind(
let origAddExperiment = ExperimentManager.store.addEnrollment.bind(
ExperimentManager.store
);
let origOnUpdatesReady = RemoteDefaultsLoader._onUpdatesReady.bind(
RemoteDefaultsLoader
);
sandbox
.stub(ExperimentManager.store, "addExperiment")
.stub(ExperimentManager.store, "addEnrollment")
.callsFake(async enrollment => {
await ExperimentTestUtils.validateEnrollment(enrollment);
return origAddExperiment(enrollment);
});
// Unlike `addExperiment` the method to store remote rollouts is syncronous
// and our validation method would turn it async. If we had changed to `await`
// for remote configs storage it would have changed the code logic so we are
// going up one level to the function that receives the RS records and do
// the validation there.
sandbox
.stub(RemoteDefaultsLoader, "_onUpdatesReady")
.callsFake(async (remoteDefaults, reason) => {
for (let remoteDefault of remoteDefaults) {
for (let config of remoteDefault.configurations) {
await ExperimentTestUtils.validateRollouts(config);
}
}
return origOnUpdatesReady(remoteDefaults, reason);
});
registerCleanupFunction(() => {
sandbox.restore();

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

@ -23,7 +23,7 @@ add_task(async function test_getExperiment_fromChild_slug() {
sandbox.stub(ExperimentAPI, "_store").get(() => ExperimentFakes.childStore());
await manager.store.addExperiment(expected);
await manager.store.addEnrollment(expected);
// Wait to sync to child
await TestUtils.waitForCondition(
@ -55,7 +55,7 @@ add_task(async function test_getExperiment_fromParent_slug() {
sandbox.stub(ExperimentAPI, "_store").get(() => manager.store);
await ExperimentAPI.ready();
await manager.store.addExperiment(expected);
await manager.store.addEnrollment(expected);
Assert.equal(
ExperimentAPI.getExperiment({ slug: "foo" }).slug,
@ -76,7 +76,37 @@ add_task(async function test_getExperimentMetaData() {
sandbox.stub(ExperimentAPI, "_store").get(() => manager.store);
await ExperimentAPI.ready();
await manager.store.addExperiment(expected);
await manager.store.addEnrollment(expected);
let metadata = ExperimentAPI.getExperimentMetaData({ slug: expected.slug });
Assert.equal(
Object.keys(metadata.branch).length,
1,
"Should only expose one property"
);
Assert.equal(
metadata.branch.slug,
expected.branch.slug,
"Should have the slug prop"
);
Assert.ok(exposureStub.notCalled, "Not called for this method");
sandbox.restore();
});
add_task(async function test_getRolloutMetaData() {
const sandbox = sinon.createSandbox();
const manager = ExperimentFakes.manager();
const expected = ExperimentFakes.rollout("foo");
let exposureStub = sandbox.stub(ExperimentAPI, "recordExposureEvent");
await manager.onStartup();
sandbox.stub(ExperimentAPI, "_store").get(() => manager.store);
await ExperimentAPI.ready();
await manager.store.addEnrollment(expected);
let metadata = ExperimentAPI.getExperimentMetaData({ slug: expected.slug });
@ -145,7 +175,7 @@ add_task(async function test_getExperiment_feature() {
sandbox.stub(ExperimentAPI, "_store").get(() => ExperimentFakes.childStore());
let exposureStub = sandbox.stub(ExperimentAPI, "recordExposureEvent");
await manager.store.addExperiment(expected);
await manager.store.addEnrollment(expected);
// Wait to sync to child
await TestUtils.waitForCondition(
@ -354,7 +384,7 @@ add_task(async function test_getAllBranches_Failure() {
* #on
* #off
*/
add_task(async function test_addExperiment_eventEmit_add() {
add_task(async function test_addEnrollment_eventEmit_add() {
const sandbox = sinon.createSandbox();
const slugStub = sandbox.stub();
const featureStub = sandbox.stub();
@ -373,7 +403,7 @@ add_task(async function test_addExperiment_eventEmit_add() {
ExperimentAPI.on("update", { slug: "foo" }, slugStub);
ExperimentAPI.on("update", { featureId: "purple" }, featureStub);
await store.addExperiment(experiment);
await store.addEnrollment(experiment);
Assert.equal(
slugStub.callCount,
@ -405,7 +435,7 @@ add_task(async function test_updateExperiment_eventEmit_add_and_update() {
await store.init();
await ExperimentAPI.ready();
await store.addExperiment(experiment);
await store.addEnrollment(experiment);
ExperimentAPI.on("update", { slug: "foo" }, slugStub);
ExperimentAPI.on("update", { featureId: "purple" }, featureStub);
@ -442,7 +472,7 @@ add_task(async function test_updateExperiment_eventEmit_off() {
ExperimentAPI.on("update", { slug: "foo" }, slugStub);
ExperimentAPI.on("update", { featureId: "purple" }, featureStub);
await store.addExperiment(experiment);
await store.addEnrollment(experiment);
ExperimentAPI.off("update:foo", slugStub);
ExperimentAPI.off("update:purple", featureStub);
@ -465,7 +495,7 @@ add_task(async function test_activateBranch() {
});
await store.init();
await store.addExperiment(experiment);
await store.addEnrollment(experiment);
Assert.deepEqual(
ExperimentAPI.activateBranch({ featureId: "green" }),
@ -505,8 +535,8 @@ add_task(async function test_activateBranch_storeFailure() {
});
await store.init();
await store.addExperiment(experiment);
// Adding stub later because `addExperiment` emits update events
await store.addEnrollment(experiment);
// Adding stub later because `addEnrollment` emits update events
const stub = sandbox.stub(store, "emit");
// Call activateBranch to trigger an activation event
sandbox.stub(store, "getAllActive").throws();
@ -532,8 +562,8 @@ add_task(async function test_activateBranch_noActivationEvent() {
});
await store.init();
await store.addExperiment(experiment);
// Adding stub later because `addExperiment` emits update events
await store.addEnrollment(experiment);
// Adding stub later because `addEnrollment` emits update events
const stub = sandbox.stub(store, "emit");
// Call activateBranch to trigger an activation event
ExperimentAPI.activateBranch({ featureId: "green" });

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

@ -7,9 +7,6 @@ const {
const { ExperimentFakes } = ChromeUtils.import(
"resource://testing-common/NimbusTestUtils.jsm"
);
const { TestUtils } = ChromeUtils.import(
"resource://testing-common/TestUtils.jsm"
);
async function setupForExperimentFeature() {
const sandbox = sinon.createSandbox();
@ -45,14 +42,15 @@ const FAKE_FEATURE_MANIFEST = {
test: {
type: "boolean",
},
title: {
type: "string",
},
},
};
const FAKE_FEATURE_REMOTE_VALUE = {
slug: "default-remote-value",
variables: {
value: {
enabled: true,
},
targeting: "true",
};
/**
@ -100,6 +98,17 @@ add_task(async function test_ExperimentFeature_isEnabled_default() {
add_task(async function test_ExperimentFeature_isEnabled_default_over_remote() {
const { manager, sandbox } = await setupForExperimentFeature();
const rollout = ExperimentFakes.rollout("foo-rollout", {
branch: {
features: [
{
featureId: "foo",
enabled: true,
value: FAKE_FEATURE_REMOTE_VALUE,
},
],
},
});
await manager.store.ready();
const featureInstance = new ExperimentFeature("foo", FAKE_FEATURE_MANIFEST);
@ -112,12 +121,7 @@ add_task(async function test_ExperimentFeature_isEnabled_default_over_remote() {
"should use the default pref value, including if it is false"
);
manager.store.updateRemoteConfigs("foo", {
...FAKE_FEATURE_REMOTE_VALUE,
variables: { enabled: true },
});
await featureInstance.ready();
await manager.store.addEnrollment(rollout);
Assert.equal(
featureInstance.isEnabled(),
@ -142,14 +146,16 @@ add_task(async function test_ExperimentFeature_test_helper_ready() {
const featureInstance = new ExperimentFeature("foo", FAKE_FEATURE_MANIFEST);
await ExperimentFakes.remoteDefaultsHelper({
feature: featureInstance,
store: manager.store,
configuration: {
...FAKE_FEATURE_REMOTE_VALUE,
variables: { remoteValue: "mochitest", enabled: true },
await ExperimentFakes.enrollWithRollout(
{
featureId: "foo",
enabled: true,
value: { remoteValue: "mochitest", enabled: true },
},
});
{
manager,
}
);
Assert.equal(featureInstance.isEnabled(), true, "enabled by remote config");
Assert.equal(
@ -176,16 +182,20 @@ add_task(
const featureInstance = new ExperimentFeature("foo", FAKE_FEATURE_MANIFEST);
await manager.store.ready();
await manager.store.addExperiment(expected);
await manager.store.addEnrollment(expected);
const exposureSpy = sandbox.spy(ExperimentAPI, "recordExposureEvent");
manager.store.updateRemoteConfigs("foo", {
...FAKE_FEATURE_REMOTE_VALUE,
variables: { enabled: false },
});
await featureInstance.ready();
await ExperimentFakes.enrollWithRollout(
{
featureId: "foo",
enabled: false,
value: { enabled: false },
},
{
manager,
}
);
Assert.equal(
featureInstance.isEnabled(),
@ -214,12 +224,53 @@ add_task(
}
);
add_task(
async function test_ExperimentFeature_isEnabled_prefer_experiment_over_remote_legacy() {
const { sandbox, manager } = await setupForExperimentFeature();
const expected = ExperimentFakes.experiment("foo", {
branch: {
slug: "treatment",
features: [
{
featureId: "foo",
enabled: true,
value: { legacy: true },
},
],
},
});
const featureInstance = new ExperimentFeature("foo", FAKE_FEATURE_MANIFEST);
await manager.store.ready();
await manager.store.addEnrollment(expected);
await ExperimentFakes.enrollWithRollout(
{
featureId: "foo",
enabled: false,
value: { legacy: true, enabled: false },
},
{
manager,
}
);
Assert.equal(
featureInstance.isEnabled(),
true,
"should return the enabled value defined in the experiment not the remote value"
);
sandbox.restore();
}
);
add_task(async function test_record_exposure_event() {
const { sandbox, manager } = await setupForExperimentFeature();
const featureInstance = new ExperimentFeature("foo", FAKE_FEATURE_MANIFEST);
const exposureSpy = sandbox.spy(ExperimentAPI, "recordExposureEvent");
const getExperimentSpy = sandbox.spy(ExperimentAPI, "getExperiment");
const getExperimentSpy = sandbox.spy(ExperimentAPI, "getExperimentMetaData");
sandbox.stub(ExperimentAPI, "_store").get(() => manager.store);
featureInstance.recordExposureEvent();
@ -229,7 +280,7 @@ add_task(async function test_record_exposure_event() {
"should not emit an exposure event when no experiment is active"
);
await manager.store.addExperiment(
await manager.store.addEnrollment(
ExperimentFakes.experiment("blah", {
branch: {
slug: "treatment",
@ -261,7 +312,7 @@ add_task(async function test_record_exposure_event_once() {
const exposureSpy = sandbox.spy(ExperimentAPI, "recordExposureEvent");
sandbox.stub(ExperimentAPI, "_store").get(() => manager.store);
await manager.store.addExperiment(
await manager.store.addEnrollment(
ExperimentFakes.experiment("blah", {
branch: {
slug: "treatment",
@ -315,39 +366,6 @@ add_task(async function test_allow_multiple_exposure_events() {
await doExperimentCleanup();
});
add_task(async function test_set_remote_before_ready() {
let sandbox = sinon.createSandbox();
const manager = ExperimentFakes.manager();
sandbox.stub(ExperimentAPI, "_store").get(() => manager.store);
const feature = new ExperimentFeature("foo", FAKE_FEATURE_MANIFEST);
await Assert.rejects(
ExperimentFakes.remoteDefaultsHelper({
feature,
store: manager.store,
configuration: {
...FAKE_FEATURE_REMOTE_VALUE,
variables: { test: true, enabled: true },
},
}),
/Store not ready/,
"Throws if used before init finishes"
);
await manager.onStartup();
await ExperimentFakes.remoteDefaultsHelper({
feature,
store: manager.store,
configuration: {
...FAKE_FEATURE_REMOTE_VALUE,
variables: { test: true, enabled: true },
},
});
Assert.ok(feature.getVariable("test"), "Successfully set");
});
add_task(async function test_isEnabled_backwards_compatible() {
const PREVIOUS_FEATURE_MANIFEST = {
variables: {
@ -365,18 +383,19 @@ add_task(async function test_isEnabled_backwards_compatible() {
await manager.onStartup();
await ExperimentFakes.remoteDefaultsHelper({
feature,
store: manager.store,
configuration: {
...FAKE_FEATURE_REMOTE_VALUE,
variables: { enabled: false },
await ExperimentFakes.enrollWithRollout(
{
featureId: "foo",
value: { enabled: false },
},
});
{
manager,
}
);
Assert.ok(!feature.isEnabled(), "Disabled based on remote configs");
await manager.store.addExperiment(
await manager.store.addEnrollment(
ExperimentFakes.experiment("blah", {
branch: {
slug: "treatment",
@ -435,21 +454,31 @@ add_task(async function test_onUpdate_before_store_ready() {
add_task(async function test_ExperimentFeature_test_ready_late() {
const { manager, sandbox } = await setupForExperimentFeature();
const stub = sandbox.stub();
await manager.store.ready();
sandbox
.stub(manager.store, "getAllRollouts")
.returns([ExperimentFakes.rollout("foo")]);
await manager.onStartup();
manager.store.finalizeRemoteConfigs([]);
const featureInstance = new ExperimentFeature("foo", FAKE_FEATURE_MANIFEST);
const featureInstance = new ExperimentFeature(
"test-feature",
FAKE_FEATURE_MANIFEST
);
featureInstance.onUpdate(stub);
// Setting a really high timeout so in case our ready function doesn't handle
// this late init + ready scenario correctly the test will time out
await featureInstance.ready(400 * 1000);
await featureInstance.ready();
Assert.ok(stub.notCalled, "We register too late to catch any events");
Assert.ok(
!featureInstance.isEnabled(),
setDefaultBranch(TEST_FALLBACK_PREF, JSON.stringify({ foo: true }));
Assert.deepEqual(
featureInstance.getVariable("config"),
{ foo: true },
"Feature is ready even when initialized after store update"
);
Assert.equal(
featureInstance.getVariable("title"),
"hello",
"Returns the NimbusTestUtils rollout default value"
);
});

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

@ -7,9 +7,6 @@ const {
const { ExperimentFakes } = ChromeUtils.import(
"resource://testing-common/NimbusTestUtils.jsm"
);
const { TestUtils } = ChromeUtils.import(
"resource://testing-common/TestUtils.jsm"
);
const { cleanupStorePrefCache } = ExperimentFakes;
@ -86,7 +83,7 @@ add_task(
},
});
await manager.store.addExperiment(recipe);
await manager.store.addEnrollment(recipe);
const featureInstance = new ExperimentFeature(
FEATURE_ID,
@ -127,6 +124,12 @@ add_task(
FEATURE_ID,
FAKE_FEATURE_MANIFEST
);
const rollout = ExperimentFakes.rollout("foo-aw", {
branch: {
slug: "getAllVariables",
features: [{ featureId: FEATURE_ID, value: { screens: [] } }],
},
});
// We're using the store in this test we need to wait for it to load
await manager.store.ready();
@ -138,13 +141,14 @@ add_task(
"Pref is not set"
);
const updatePromise = new Promise(resolve =>
featureInstance.onUpdate(resolve)
);
// Load remote defaults
manager.store.updateRemoteConfigs(FEATURE_ID, {
variables: { screens: [] },
});
manager.store.addEnrollment(rollout);
// Wait for feature to load remote defaults
await featureInstance.ready();
// Wait for feature to load the rollout
await updatePromise;
Assert.deepEqual(
featureInstance.getAllVariables().screens?.length,

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

@ -4,9 +4,6 @@ const {
ExperimentAPI,
_ExperimentFeature: ExperimentFeature,
} = ChromeUtils.import("resource://nimbus/ExperimentAPI.jsm");
const { TestUtils } = ChromeUtils.import(
"resource://testing-common/TestUtils.jsm"
);
const { AppConstants } = ChromeUtils.import(
"resource://gre/modules/AppConstants.jsm"
);
@ -92,6 +89,16 @@ add_task(async function test_ExperimentFeature_getVariable_precedence() {
const instance = createInstanceWithVariables(TEST_VARIABLES);
const prefName = TEST_VARIABLES.items.fallbackPref;
const rollout = ExperimentFakes.rollout(`${FEATURE_ID}-rollout`, {
branch: {
features: [
{
featureId: FEATURE_ID,
value: { items: [4, 5, 6] },
},
],
},
});
Services.prefs.clearUserPref(prefName);
@ -113,9 +120,7 @@ add_task(async function test_ExperimentFeature_getVariable_precedence() {
);
// Remote default values
manager.store.updateRemoteConfigs(FEATURE_ID, {
variables: { items: [4, 5, 6] },
});
await manager.store.addEnrollment(rollout);
Assert.deepEqual(
instance.getVariable("items"),
@ -157,16 +162,23 @@ add_task(async function test_ExperimentFeature_getVariable_precedence() {
add_task(async function test_ExperimentFeature_getVariable_partial_values() {
const { sandbox, manager } = await setupForExperimentFeature();
const instance = createInstanceWithVariables(TEST_VARIABLES);
const rollout = ExperimentFakes.rollout(`${FEATURE_ID}-rollout`, {
branch: {
features: [
{
featureId: FEATURE_ID,
value: { name: "abc" },
},
],
},
});
// Set up a pref value for .enabled,
// a remote value for .name,
// an experiment value for .items
Services.prefs.setBoolPref(TEST_VARIABLES.enabled.fallbackPref, true);
manager.store.updateRemoteConfigs(FEATURE_ID, {
variables: { name: "abc" },
});
await manager.store.addEnrollment(rollout);
const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig(
{
featureId: FEATURE_ID,

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

@ -2,57 +2,46 @@
const {
ExperimentAPI,
NimbusFeatures,
_ExperimentFeature: ExperimentFeature,
} = ChromeUtils.import("resource://nimbus/ExperimentAPI.jsm");
const { TestUtils } = ChromeUtils.import(
"resource://testing-common/TestUtils.jsm"
);
Cu.importGlobalProperties(["fetch"]);
XPCOMUtils.defineLazyGetter(this, "fetchSchema", async () => {
const response = await fetch(
"resource://testing-common/ExperimentFeatureRemote.schema.json"
"resource://testing-common/NimbusEnrollment.schema.json"
);
const schema = await response.json();
if (!schema) {
throw new Error("Failed to load ExperimentFeatureRemote schema");
}
return schema.definitions.RemoteFeatureConfigurations;
return schema.definitions.NimbusExperiment;
});
const REMOTE_CONFIGURATION = Object.freeze({
id: "aboutwelcome",
configurations: [
{
slug: "non-matching-configuration",
description: "This configuration does not match because of targeting.",
variables: { skipFocus: false, enabled: false },
targeting: "false",
bucketConfig: {
namespace: "nimbus-test-utils",
randomizationUnit: "normandy_id",
start: 0,
count: 1000,
total: 1000,
},
const NON_MATCHING_ROLLOUT = Object.freeze(
ExperimentFakes.rollout("non-matching-rollout", {
branch: {
features: [
{
featureId: "aboutwelcome",
value: { skipFocus: false, enabled: false },
},
],
},
{
slug: "matching-configuration",
description: "This configuration will match targeting.",
variables: { skipFocus: true, enabled: true },
targeting: "true",
bucketConfig: {
namespace: "nimbus-test-utils",
randomizationUnit: "normandy_id",
start: 0,
count: 1000,
total: 1000,
},
})
);
const MATCHING_ROLLOUT = Object.freeze(
ExperimentFakes.rollout("matching-rollout", {
branch: {
features: [
{
featureId: "aboutwelcome",
value: { skipFocus: false, enabled: true },
},
],
},
],
});
})
);
const AW_FAKE_MANIFEST = {
description: "Different manifest with a special test variable",
@ -90,7 +79,11 @@ add_task(async function validSchema() {
const validate = ajv.compile(await fetchSchema);
Assert.ok(
validate(REMOTE_CONFIGURATION),
validate(NON_MATCHING_ROLLOUT),
JSON.stringify(validate.errors, null, 2)
);
Assert.ok(
validate(MATCHING_ROLLOUT),
JSON.stringify(validate.errors, null, 2)
);
});
@ -101,13 +94,7 @@ add_task(async function readyCallAfterStore_with_remote_value() {
Assert.ok(feature.getVariable("skipFocus"), "Feature is true by default");
manager.store.updateRemoteConfigs(
"aboutwelcome",
REMOTE_CONFIGURATION.configurations[0]
);
// Should resolve because the store will return a dummy remote value
await feature.ready();
await manager.store.addEnrollment(MATCHING_ROLLOUT);
Assert.ok(!feature.getVariable("skipFocus"), "Loads value from store");
manager.store._deleteForTests("aboutwelcome");
@ -126,7 +113,10 @@ add_task(async function has_sync_value_before_ready() {
Services.prefs.setStringPref(
"nimbus.syncdefaultsstore.aboutwelcome",
JSON.stringify(REMOTE_CONFIGURATION.configurations[0])
JSON.stringify({
...MATCHING_ROLLOUT,
branch: { feature: MATCHING_ROLLOUT.branch.features[0] },
})
);
Services.prefs.setBoolPref(
@ -146,20 +136,63 @@ add_task(async function update_remote_defaults_onUpdate() {
feature.onUpdate(stub);
manager.store.updateRemoteConfigs(
"aboutwelcome",
REMOTE_CONFIGURATION.configurations[0]
);
await manager.store.addEnrollment(MATCHING_ROLLOUT);
Assert.ok(stub.called, "update event called");
Assert.equal(stub.callCount, 1, "Called once for remote configs");
Assert.equal(
stub.firstCall.args[1],
"remote-defaults-update",
"Correct reason"
Assert.equal(stub.firstCall.args[1], "rollout-updated", "Correct reason");
manager.store._deleteForTests("aboutwelcome");
sandbox.restore();
});
add_task(async function test_features_over_feature() {
let { sandbox, manager } = await setupForExperimentFeature();
let feature = new ExperimentFeature("aboutwelcome");
const rollout_features_and_feature = Object.freeze(
ExperimentFakes.rollout("matching-rollout", {
branch: {
feature: {
featureId: "aboutwelcome",
value: { enabled: false },
},
features: [
{
featureId: "aboutwelcome",
value: { skipFocus: false, enabled: true },
},
],
},
})
);
const rollout_just_feature = Object.freeze(
ExperimentFakes.rollout("matching-rollout", {
branch: {
feature: {
featureId: "aboutwelcome",
value: { enabled: false },
},
},
})
);
await manager.store.addEnrollment(rollout_features_and_feature);
Assert.ok(
feature.getVariable("enabled"),
"Should read from the features property over feature"
);
manager.store._deleteForTests("aboutwelcome");
manager.store._deleteForTests("matching-rollout");
await manager.store.addEnrollment(rollout_just_feature);
Assert.ok(
!feature.getVariable("enabled"),
"Should read from the feature property when features doesn't exist"
);
manager.store._deleteForTests("aboutwelcome");
manager.store._deleteForTests("matching-rollout");
sandbox.restore();
});
@ -167,16 +200,16 @@ add_task(async function update_remote_defaults_readyPromise() {
let { sandbox, manager } = await setupForExperimentFeature();
let feature = new ExperimentFeature("aboutwelcome");
let stub = sandbox.stub();
let promise = feature.ready();
feature.onUpdate(stub);
manager.store.updateRemoteConfigs(
"aboutwelcome",
REMOTE_CONFIGURATION.configurations[1]
);
await manager.store.addEnrollment(MATCHING_ROLLOUT);
await promise;
Assert.ok(stub.calledOnce, "Update called after enrollment processed.");
Assert.ok(
stub.calledWith("featureUpdate:aboutwelcome", "rollout-updated"),
"Update called after enrollment processed."
);
manager.store._deleteForTests("aboutwelcome");
sandbox.restore();
@ -185,7 +218,6 @@ add_task(async function update_remote_defaults_readyPromise() {
add_task(async function update_remote_defaults_enabled() {
let { sandbox, manager } = await setupForExperimentFeature();
let feature = new ExperimentFeature("aboutwelcome");
let promise = feature.ready();
Assert.equal(
feature.isEnabled(),
@ -193,12 +225,7 @@ add_task(async function update_remote_defaults_enabled() {
"Feature is enabled by manifest.variables.enabled"
);
manager.store.updateRemoteConfigs(
"aboutwelcome",
REMOTE_CONFIGURATION.configurations[0]
);
await promise;
await manager.store.addEnrollment(NON_MATCHING_ROLLOUT);
Assert.ok(
!feature.isEnabled(),
@ -233,16 +260,22 @@ add_task(async function test_getVariable_no_mutation() {
add_task(async function remote_isEarlyStartup_config() {
let { manager } = await setupForExperimentFeature();
let feature = new ExperimentFeature("password-autocomplete");
manager.store.updateRemoteConfigs("password-autocomplete", {
slug: "remote-config-isEarlyStartup",
description: "This feature normally is not marked isEarlyStartup",
variables: { remote: true },
isEarlyStartup: true,
let rollout = ExperimentFakes.rollout("password-autocomplete", {
branch: {
slug: "remote-config-isEarlyStartup",
features: [
{
featureId: "password-autocomplete",
enabled: true,
value: { remote: true },
isEarlyStartup: true,
},
],
},
});
await feature.ready();
await manager.onStartup();
await manager.store.addEnrollment(rollout);
Assert.ok(
Services.prefs.prefHasUserValue(

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

@ -12,11 +12,14 @@ add_task(async function test_createTargetingContext() {
const manager = ExperimentFakes.manager();
const sandbox = sinon.createSandbox();
const recipe = ExperimentFakes.recipe("foo");
const rollout = ExperimentFakes.rollout("bar");
sandbox.stub(manager.store, "ready").resolves();
sandbox.stub(manager.store, "getAllActive").returns([recipe]);
sandbox.stub(manager.store, "getAllRollouts").returns([rollout]);
let context = manager.createTargetingContext();
const activeSlugs = await context.activeExperiments;
const activeRollouts = await context.activeRollouts;
Assert.ok(!context.isFirstStartup, "should not set the first startup flag");
Assert.deepEqual(
@ -24,6 +27,11 @@ add_task(async function test_createTargetingContext() {
["foo"],
"should return slugs for all the active experiment"
);
Assert.deepEqual(
activeRollouts,
["bar"],
"should return slugs for all rollouts stored"
);
// Pretend to be in the first startup
FirstStartup._state = FirstStartup.IN_PROGRESS;

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

@ -9,9 +9,6 @@ const { Sampling } = ChromeUtils.import(
const { ClientEnvironment } = ChromeUtils.import(
"resource://normandy/lib/ClientEnvironment.jsm"
);
const { TestUtils } = ChromeUtils.import(
"resource://testing-common/TestUtils.jsm"
);
const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
const { cleanupStorePrefCache } = ExperimentFakes;
@ -19,8 +16,21 @@ const { cleanupStorePrefCache } = ExperimentFakes;
const { ExperimentStore } = ChromeUtils.import(
"resource://nimbus/lib/ExperimentStore.jsm"
);
const { TelemetryEnvironment } = ChromeUtils.import(
"resource://gre/modules/TelemetryEnvironment.jsm"
);
const { TelemetryEvents } = ChromeUtils.import(
"resource://normandy/lib/TelemetryEvents.jsm"
);
const { SYNC_DATA_PREF_BRANCH } = ExperimentStore;
const { SYNC_DATA_PREF_BRANCH, SYNC_DEFAULTS_PREF_BRANCH } = ExperimentStore;
const globalSandbox = sinon.createSandbox();
globalSandbox.spy(TelemetryEnvironment, "setExperimentInactive");
globalSandbox.spy(TelemetryEvents, "sendEvent");
registerCleanupFunction(() => {
globalSandbox.restore();
});
/**
* The normal case: Enrollment of a new experiment
@ -50,6 +60,39 @@ add_task(async function test_add_to_store() {
);
});
add_task(async function test_add_rollout_to_store() {
const manager = ExperimentFakes.manager();
const recipe = {
...ExperimentFakes.recipe("rollout-slug"),
branches: [ExperimentFakes.rollout("rollout").branch],
isRollout: true,
active: true,
bucketConfig: {
namespace: "nimbus-test-utils",
randomizationUnit: "normandy_id",
start: 0,
count: 1000,
total: 1000,
},
};
const enrollPromise = new Promise(resolve =>
manager.store.on("update:rollout-slug", resolve)
);
await manager.onStartup();
await manager.enroll(recipe, "test_add_rollout_to_store");
await enrollPromise;
const experiment = manager.store.get("rollout-slug");
Assert.ok(experiment, `Should add an experiment with slug ${recipe.slug}`);
Assert.ok(
recipe.branches.includes(experiment.branch),
"should choose a branch from the recipe.branches"
);
Assert.equal(experiment.isRollout, true, "should have .isRollout");
});
add_task(
async function test_setExperimentActive_sendEnrollmentTelemetry_called() {
const manager = ExperimentFakes.manager();
@ -85,6 +128,76 @@ add_task(
}
);
add_task(async function test_setRolloutActive_sendEnrollmentTelemetry_called() {
globalSandbox.reset();
globalSandbox.spy(TelemetryEnvironment, "setExperimentActive");
globalSandbox.spy(TelemetryEvents.sendEvent);
const manager = ExperimentFakes.manager();
const sandbox = sinon.createSandbox();
const rolloutRecipe = {
...ExperimentFakes.recipe("rollout"),
branches: [ExperimentFakes.rollout("rollout").branch],
isRollout: true,
};
const enrollPromise = new Promise(resolve =>
manager.store.on("update:rollout", resolve)
);
sandbox.spy(manager, "setExperimentActive");
sandbox.spy(manager, "sendEnrollmentTelemetry");
await manager.onStartup();
let result = await manager.enroll(
rolloutRecipe,
"test_setRolloutActive_sendEnrollmentTelemetry_called"
);
await enrollPromise;
const enrollment = manager.store.get("rollout");
Assert.ok(!!result && !!enrollment, "Enrollment was successful");
Assert.equal(
TelemetryEnvironment.setExperimentActive.called,
true,
"should call setExperimentActive"
);
Assert.ok(
manager.setExperimentActive.calledWith(enrollment),
"Should call setExperimentActive with the rollout"
);
Assert.equal(
manager.setExperimentActive.firstCall.args[0].experimentType,
"rollout",
"Should have the correct experimentType"
);
Assert.equal(
manager.sendEnrollmentTelemetry.calledWith(enrollment),
true,
"should call sendEnrollmentTelemetry after an enrollment"
);
Assert.ok(
TelemetryEvents.sendEvent.calledOnce,
"Should send out enrollment telemetry"
);
Assert.ok(
TelemetryEvents.sendEvent.calledWith(
"enroll",
sinon.match.string,
enrollment.slug,
{
experimentType: "rollout",
branch: enrollment.branch.slug,
enrollmentId: enrollment.enrollmentId,
}
),
"Should send telemetry with expected values"
);
globalSandbox.restore();
});
/**
* Failure cases:
* - slug conflict
@ -99,7 +212,7 @@ add_task(async function test_failure_name_conflict() {
await manager.onStartup();
// simulate adding a previouly enrolled experiment
await manager.store.addExperiment(ExperimentFakes.experiment("foo"));
await manager.store.addEnrollment(ExperimentFakes.experiment("foo"));
await Assert.rejects(
manager.enroll(ExperimentFakes.recipe("foo"), "test_failure_name_conflict"),
@ -137,7 +250,7 @@ add_task(async function test_failure_group_conflict() {
};
// simulate adding an experiment with a conflicting group "pink"
await manager.store.addExperiment(
await manager.store.addEnrollment(
ExperimentFakes.experiment("foo", {
branch: existingBranch,
})
@ -165,6 +278,66 @@ add_task(async function test_failure_group_conflict() {
);
});
add_task(async function test_rollout_failure_group_conflict() {
const manager = ExperimentFakes.manager();
const sandbox = sinon.createSandbox();
const rollout = ExperimentFakes.rollout("rollout-enrollment");
const recipe = {
...ExperimentFakes.recipe("rollout-recipe"),
branches: [rollout.branch],
isRollout: true,
};
sandbox.spy(manager, "sendFailureTelemetry");
await manager.onStartup();
// simulate adding an experiment with a conflicting group "pink"
await manager.store.addEnrollment(rollout);
Assert.equal(
await manager.enroll(recipe, "test_rollout_failure_group_conflict"),
null,
"should not enroll if there is a feature conflict"
);
Assert.equal(
manager.sendFailureTelemetry.calledWith(
"enrollFailed",
recipe.slug,
"feature-conflict"
),
true,
"should send failure telemetry if a feature conflict exists"
);
});
add_task(async function test_rollout_experiment_no_conflict() {
const manager = ExperimentFakes.manager();
const sandbox = sinon.createSandbox();
const experiment = ExperimentFakes.experiment("rollout-enrollment");
const recipe = {
...ExperimentFakes.recipe("rollout-recipe"),
branches: [experiment.branch],
isRollout: true,
};
sandbox.spy(manager, "sendFailureTelemetry");
await manager.onStartup();
await manager.store.addEnrollment(experiment);
Assert.equal(
(await manager.enroll(recipe, "test_rollout_failure_group_conflict"))?.slug,
recipe.slug,
"Experiment and Rollouts can exists for the same feature"
);
Assert.ok(
manager.sendFailureTelemetry.notCalled,
"Should send failure telemetry if a feature conflict exists"
);
});
add_task(async function test_sampling_check() {
const manager = ExperimentFakes.manager();
let recipe = ExperimentFakes.recipe("foo", { bucketConfig: null });
@ -259,6 +432,9 @@ add_task(async function enroll_in_reference_aw_experiment() {
// In case some regression causes us to store a significant amount of data
// in prefs.
Assert.ok(prefValue.length < 3498, "Make sure we don't bloat the prefs");
manager.unenroll(recipe.slug, "enroll_in_reference_aw_experiment:cleanup");
manager.store._deleteForTests("aboutwelcome");
});
add_task(async function test_forceEnroll_cleanup() {
@ -319,6 +495,28 @@ add_task(async function test_forceEnroll_cleanup() {
sandbox.restore();
});
add_task(async function test_rollout_unenroll_conflict() {
const manager = ExperimentFakes.manager();
const sandbox = sinon.createSandbox();
let unenrollStub = sandbox.stub(manager, "unenroll").returns(true);
let enrollStub = sandbox.stub(manager, "_enroll").returns(true);
let rollout = ExperimentFakes.rollout("rollout_conflict");
// We want to force a conflict
sandbox.stub(manager.store, "getRolloutForFeature").returns(rollout);
manager.forceEnroll(rollout, rollout.branch);
Assert.ok(unenrollStub.calledOnce, "Should unenroll the conflicting rollout");
Assert.ok(
unenrollStub.calledWith(rollout.slug, "force-enrollment"),
"Should call with expected slug"
);
Assert.ok(enrollStub.calledOnce, "Should call enroll as expected");
sandbox.restore();
});
add_task(async function test_featuremanifest_enum() {
let recipe = ExperimentFakes.recipe("featuremanifest_enum_success", {
branches: [
@ -368,7 +566,7 @@ add_task(async function test_featuremanifest_enum() {
});
await Assert.rejects(
manager.store.addExperiment(experiment),
manager.store.addEnrollment(experiment),
/promoLinkType should have one of the following values/,
"This should fail because of invalid feature value"
);
@ -390,8 +588,8 @@ add_task(async function test_featureIds_is_stored() {
await manager.enroll(recipe, "test_featureIds_is_stored");
Assert.ok(manager.store.addExperiment.calledOnce, "experiment is stored");
let [enrollment] = manager.store.addExperiment.firstCall.args;
Assert.ok(manager.store.addEnrollment.calledOnce, "experiment is stored");
let [enrollment] = manager.store.addEnrollment.firstCall.args;
Assert.ok("featureIds" in enrollment, "featureIds is stored");
Assert.deepEqual(
enrollment.featureIds,
@ -399,3 +597,66 @@ add_task(async function test_featureIds_is_stored() {
"Has expected value"
);
});
add_task(async function experiment_and_rollout_enroll_and_cleanup() {
let store = ExperimentFakes.store();
const manager = ExperimentFakes.manager(store);
await manager.onStartup();
let rolloutCleanup = await ExperimentFakes.enrollWithRollout(
{
featureId: "aboutwelcome",
value: { enabled: true },
},
{
manager,
}
);
let experimentCleanup = await ExperimentFakes.enrollWithFeatureConfig(
{
featureId: "aboutwelcome",
value: { enabled: true },
},
{ manager }
);
Assert.ok(
Services.prefs.getBoolPref(`${SYNC_DATA_PREF_BRANCH}aboutwelcome.enabled`)
);
Assert.ok(
Services.prefs.getBoolPref(
`${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome.enabled`
)
);
await experimentCleanup();
Assert.ok(
!Services.prefs.getBoolPref(
`${SYNC_DATA_PREF_BRANCH}aboutwelcome.enabled`,
false
)
);
Assert.ok(
Services.prefs.getBoolPref(
`${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome.enabled`
)
);
await rolloutCleanup();
Assert.ok(
!Services.prefs.getBoolPref(
`${SYNC_DATA_PREF_BRANCH}aboutwelcome.enabled`,
false
)
);
Assert.ok(
!Services.prefs.getBoolPref(
`${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome.enabled`,
false
)
);
});

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

@ -1,17 +1,8 @@
"use strict";
const { _ExperimentManager } = ChromeUtils.import(
"resource://nimbus/lib/ExperimentManager.jsm"
);
const { ExperimentStore } = ChromeUtils.import(
"resource://nimbus/lib/ExperimentStore.jsm"
);
const { Sampling } = ChromeUtils.import(
"resource://gre/modules/components-utils/Sampling.jsm"
);
const { TelemetryTestUtils } = ChromeUtils.import(
"resource://testing-common/TelemetryTestUtils.jsm"
);
/**
* onStartup()
@ -52,6 +43,26 @@ add_task(async function test_onStartup_setExperimentActive_called() {
);
});
add_task(async function test_onStartup_setRolloutActive_called() {
const manager = ExperimentFakes.manager();
const sandbox = sinon.createSandbox();
sandbox.stub(manager, "setExperimentActive");
sandbox.stub(manager.store, "init").resolves();
const active = ["foo", "bar"].map(ExperimentFakes.rollout);
sandbox.stub(manager.store, "getAll").returns(active);
await manager.onStartup();
active.forEach(r =>
Assert.equal(
manager.setExperimentActive.calledWith(r),
true,
`should call setExperimentActive for rollout: ${r.slug}`
)
);
});
/**
* onRecipe()
* - should add recipe slug to .session[source]
@ -133,6 +144,68 @@ add_task(async function test_onRecipe_update() {
);
});
add_task(async function test_onRecipe_rollout_update() {
const manager = ExperimentFakes.manager();
const sandbox = sinon.createSandbox();
sandbox.spy(manager, "enroll");
sandbox.spy(manager, "unenroll");
sandbox.spy(manager, "updateEnrollment");
sandbox.stub(manager, "isInBucketAllocation").resolves(true);
const fooRecipe = {
...ExperimentFakes.recipe("foo"),
isRollout: true,
};
// Rollouts should only have 1 branch
fooRecipe.branches = fooRecipe.branches.slice(0, 1);
const experimentUpdate = new Promise(resolve =>
manager.store.on(`update:${fooRecipe.slug}`, resolve)
);
await manager.onStartup();
await manager.onRecipe(fooRecipe, "test");
// onRecipe calls enroll which saves the experiment in the store
// but none of them wait on disk operations to finish
await experimentUpdate;
// Call again after recipe has already been enrolled
await manager.onRecipe(fooRecipe, "test");
Assert.equal(
manager.updateEnrollment.calledWith(fooRecipe),
true,
"should call .updateEnrollment() if the recipe has already been enrolled"
);
Assert.ok(
manager.updateEnrollment.alwaysReturned(true),
"updateEnrollment will confirm the enrolled branch still exists in the recipe and exit"
);
Assert.ok(
manager.unenroll.notCalled,
"Should not call if the branches did not change"
);
// We call again but this time we change the branch slug
// Has to be a deep clone otherwise you're changing the
// value found in the experiment store
let recipeClone = Cu.cloneInto(fooRecipe, {});
recipeClone.branches[0].slug = "control-v2";
await manager.onRecipe(recipeClone, "test");
Assert.equal(
manager.updateEnrollment.calledWith(recipeClone),
true,
"should call .updateEnrollment() if the recipe has already been enrolled"
);
Assert.ok(
manager.unenroll.called,
"updateEnrollment will unenroll because the branch slug changed"
);
Assert.ok(
manager.unenroll.calledWith(fooRecipe.slug, "branch-removed"),
"updateEnrollment will unenroll because the branch slug changed"
);
});
add_task(async function test_onRecipe_isEnrollmentPaused() {
const manager = ExperimentFakes.manager();
const sandbox = sinon.createSandbox();
@ -194,7 +267,7 @@ add_task(async function test_onFinalize_unenroll() {
lastSeen: Date.now().toLocaleString(),
source: "test",
});
await manager.store.addExperiment(recipe0);
await manager.store.addEnrollment(recipe0);
const recipe1 = ExperimentFakes.recipe("bar");
// Unique features to prevent overlap
@ -242,7 +315,7 @@ add_task(async function test_onFinalize_unenroll_mismatch() {
lastSeen: Date.now().toLocaleString(),
source: "test",
});
await manager.store.addExperiment(recipe0);
await manager.store.addEnrollment(recipe0);
const recipe1 = ExperimentFakes.recipe("bar");
// Unique features to prevent overlap
@ -273,3 +346,27 @@ add_task(async function test_onFinalize_unenroll_mismatch() {
"should clear sessions[test]"
);
});
add_task(async function test_onFinalize_rollout_unenroll() {
const manager = ExperimentFakes.manager();
const sandbox = sinon.createSandbox();
sandbox.spy(manager, "unenroll");
await manager.onStartup();
let rollout = ExperimentFakes.rollout("rollout");
await manager.store.addEnrollment(rollout);
manager.onFinalize("NimbusTestUtils");
Assert.equal(
manager.unenroll.callCount,
1,
"should only call unenroll for the unseen recipe"
);
Assert.equal(
manager.unenroll.calledWith("rollout", "recipe-not-seen"),
true,
"should unenroll a experiment whose recipe wasn't seen in the current session"
);
});

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

@ -1,8 +1,5 @@
"use strict";
const { NormandyTestUtils } = ChromeUtils.import(
"resource://testing-common/NormandyTestUtils.jsm"
);
const { TelemetryEvents } = ChromeUtils.import(
"resource://normandy/lib/TelemetryEvents.jsm"
);
@ -19,7 +16,7 @@ registerCleanupFunction(() => {
});
/**
* Normal unenrollment:
* Normal unenrollment for experiments:
* - set .active to false
* - set experiment inactive in telemetry
* - send unrollment event
@ -28,7 +25,7 @@ add_task(async function test_set_inactive() {
const manager = ExperimentFakes.manager();
await manager.onStartup();
await manager.store.addExperiment(ExperimentFakes.experiment("foo"));
await manager.store.addEnrollment(ExperimentFakes.experiment("foo"));
manager.unenroll("foo", "some-reason");
@ -46,7 +43,7 @@ add_task(async function test_unenroll_opt_out() {
const experiment = ExperimentFakes.experiment("foo");
await manager.onStartup();
await manager.store.addExperiment(experiment);
await manager.store.addEnrollment(experiment);
Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, false);
@ -80,7 +77,7 @@ add_task(async function test_setExperimentInactive_called() {
const experiment = ExperimentFakes.experiment("foo");
await manager.onStartup();
await manager.store.addExperiment(experiment);
await manager.store.addEnrollment(experiment);
manager.unenroll("foo", "some-reason");
@ -96,7 +93,7 @@ add_task(async function test_send_unenroll_event() {
const experiment = ExperimentFakes.experiment("foo");
await manager.onStartup();
await manager.store.addExperiment(experiment);
await manager.store.addEnrollment(experiment);
manager.unenroll("foo", "some-reason");
@ -123,7 +120,7 @@ add_task(async function test_undefined_reason() {
const experiment = ExperimentFakes.experiment("foo");
await manager.onStartup();
await manager.store.addExperiment(experiment);
await manager.store.addEnrollment(experiment);
manager.unenroll("foo");
@ -138,3 +135,89 @@ add_task(async function test_undefined_reason() {
"should include unknown as the reason if none was supplied"
);
});
/**
* Normal unenrollment for rollouts:
* - remove stored enrollment and synced data (prefs)
* - set rollout inactive in telemetry
* - send unrollment event
*/
add_task(async function test_remove_rollouts() {
const store = ExperimentFakes.store();
const manager = ExperimentFakes.manager(store);
const rollout = ExperimentFakes.rollout("foo");
sinon.stub(store, "get").returns(rollout);
sinon.spy(store, "updateExperiment");
await manager.onStartup();
manager.unenroll("foo", "some-reason");
Assert.ok(
manager.store.updateExperiment.calledOnce,
"Called to set the rollout as !active"
);
Assert.ok(
manager.store.updateExperiment.calledWith(rollout.slug, { active: false }),
"Called with expected parameters"
);
});
add_task(async function test_remove_rollout_onFinalize() {
const store = ExperimentFakes.store();
const manager = ExperimentFakes.manager(store);
const rollout = ExperimentFakes.rollout("foo");
sinon.stub(store, "getAllRollouts").returns([rollout]);
sinon.stub(store, "get").returns(rollout);
sinon.spy(manager, "unenroll");
sinon.spy(manager, "sendFailureTelemetry");
await manager.onStartup();
manager.onFinalize("NimbusTestUtils");
Assert.ok(manager.sendFailureTelemetry.notCalled, "Nothing should fail");
Assert.ok(manager.unenroll.calledOnce, "Should unenroll recipe not seen");
Assert.ok(manager.unenroll.calledWith(rollout.slug, "recipe-not-seen"));
});
add_task(async function test_rollout_telemetry_events() {
globalSandbox.restore();
const store = ExperimentFakes.store();
const manager = ExperimentFakes.manager(store);
const rollout = ExperimentFakes.rollout("foo");
globalSandbox.spy(TelemetryEnvironment, "setExperimentInactive");
globalSandbox.spy(TelemetryEvents, "sendEvent");
sinon.stub(store, "getAllRollouts").returns([rollout]);
sinon.stub(store, "get").returns(rollout);
sinon.spy(manager, "sendFailureTelemetry");
await manager.onStartup();
manager.onFinalize("NimbusTestUtils");
Assert.ok(manager.sendFailureTelemetry.notCalled, "Nothing should fail");
Assert.ok(
TelemetryEnvironment.setExperimentInactive.calledOnce,
"Should unenroll recipe not seen"
);
Assert.ok(
TelemetryEnvironment.setExperimentInactive.calledWith(rollout.slug),
"Should set rollout to inactive."
);
Assert.ok(
TelemetryEvents.sendEvent.calledWith(
"unenroll",
sinon.match.string,
rollout.slug,
sinon.match.object
),
"Should send unenroll event for rollout."
);
globalSandbox.restore();
});

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

@ -30,7 +30,7 @@ add_task(async function test_usageBeforeInitialization() {
Assert.equal(store.getAll().length, 0, "It should not fail");
await store.init();
store.addExperiment(experiment);
store.addEnrollment(experiment);
Assert.equal(
store.getExperimentForFeature("purple"),
@ -52,7 +52,7 @@ add_task(async function test_event_add_experiment() {
store.on("update:foo", updateEventCbStub);
// Add some data
store.addExperiment(expected);
store.addEnrollment(expected);
Assert.equal(updateEventCbStub.callCount, 1, "Called once for add");
@ -74,7 +74,7 @@ add_task(async function test_event_updates_main() {
updateEventCbStub
);
store.addExperiment(experiment);
store.addEnrollment(experiment);
store.updateExperiment("foo", { active: false });
Assert.equal(
@ -104,8 +104,8 @@ add_task(async function test_getExperimentForGroup() {
});
await store.init();
store.addExperiment(ExperimentFakes.experiment("bar"));
store.addExperiment(experiment);
store.addEnrollment(ExperimentFakes.experiment("bar"));
store.addEnrollment(experiment);
Assert.equal(
store.getExperimentForFeature("purple"),
@ -118,7 +118,7 @@ add_task(async function test_hasExperimentForFeature() {
const store = ExperimentFakes.store();
await store.init();
store.addExperiment(
store.addEnrollment(
ExperimentFakes.experiment("foo", {
branch: {
slug: "variant",
@ -126,7 +126,7 @@ add_task(async function test_hasExperimentForFeature() {
},
})
);
store.addExperiment(
store.addEnrollment(
ExperimentFakes.experiment("foo2", {
branch: {
slug: "variant",
@ -134,7 +134,7 @@ add_task(async function test_hasExperimentForFeature() {
},
})
);
store.addExperiment(
store.addEnrollment(
ExperimentFakes.experiment("bar_expired", {
active: false,
branch: {
@ -173,9 +173,9 @@ add_task(async function test_getAll_getAllActive() {
await store.init();
["foo", "bar", "baz"].forEach(slug =>
store.addExperiment(ExperimentFakes.experiment(slug, { active: false }))
store.addEnrollment(ExperimentFakes.experiment(slug, { active: false }))
);
store.addExperiment(ExperimentFakes.experiment("qux", { active: true }));
store.addEnrollment(ExperimentFakes.experiment("qux", { active: true }));
Assert.deepEqual(
store.getAll().map(e => e.slug),
@ -189,16 +189,69 @@ add_task(async function test_getAll_getAllActive() {
);
});
add_task(async function test_addExperiment() {
add_task(async function test_getAll_getAllActive_no_rollouts() {
const store = ExperimentFakes.store();
await store.init();
["foo", "bar", "baz"].forEach(slug =>
store.addEnrollment(ExperimentFakes.experiment(slug, { active: false }))
);
store.addEnrollment(ExperimentFakes.experiment("qux", { active: true }));
store.addEnrollment(ExperimentFakes.rollout("rol"));
Assert.deepEqual(
store.getAll().map(e => e.slug),
["foo", "bar", "baz", "qux", "rol"],
".getAll() should return all experiments and rollouts"
);
Assert.deepEqual(
store.getAllActive().map(e => e.slug),
["qux"],
".getAllActive() should return all experiments that are active and no rollouts"
);
});
add_task(async function test_getAllRollouts() {
const store = ExperimentFakes.store();
await store.init();
["foo", "bar", "baz"].forEach(slug =>
store.addEnrollment(ExperimentFakes.rollout(slug))
);
store.addEnrollment(ExperimentFakes.experiment("qux", { active: true }));
Assert.deepEqual(
store.getAll().map(e => e.slug),
["foo", "bar", "baz", "qux"],
".getAll() should return all experiments and rollouts"
);
Assert.deepEqual(
store.getAllRollouts().map(e => e.slug),
["foo", "bar", "baz"],
".getAllRollouts() should return all rollouts"
);
});
add_task(async function test_addEnrollment_experiment() {
const store = ExperimentFakes.store();
const exp = ExperimentFakes.experiment("foo");
await store.init();
store.addExperiment(exp);
store.addEnrollment(exp);
Assert.equal(store.get("foo"), exp, "should save experiment by slug");
});
add_task(async function test_addEnrollment_rollout() {
const store = ExperimentFakes.store();
const rollout = ExperimentFakes.rollout("foo");
await store.init();
store.addEnrollment(rollout);
Assert.equal(store.get("foo"), rollout, "should save rollout by slug");
});
add_task(async function test_updateExperiment() {
const features = [{ featureId: "cfr", enabled: true }];
const experiment = Object.freeze(
@ -207,7 +260,7 @@ add_task(async function test_updateExperiment() {
const store = ExperimentFakes.store();
await store.init();
store.addExperiment(experiment);
store.addEnrollment(experiment);
store.updateExperiment("foo", { active: false });
const actual = store.get("foo");
@ -230,7 +283,7 @@ add_task(async function test_sync_access_before_init() {
features: [{ featureId: "newtab", enabled: "true" }],
});
await store.init();
store.addExperiment(syncAccessExp);
store.addEnrollment(syncAccessExp);
let prefValue;
try {
@ -264,7 +317,7 @@ add_task(async function test_sync_access_update() {
await store.init();
store.addExperiment(experiment);
store.addEnrollment(experiment);
store.updateExperiment("foo", {
branch: {
...experiment.branch,
@ -301,7 +354,7 @@ add_task(async function test_sync_features_only() {
await store.init();
store.addExperiment(experiment);
store.addEnrollment(experiment);
store = ExperimentFakes.store();
Assert.equal(store.getAll().length, 0, "cfr is not a sync access experiment");
@ -317,7 +370,7 @@ add_task(async function test_sync_features_remotely() {
await store.init();
store.addExperiment(experiment);
store.addEnrollment(experiment);
store = ExperimentFakes.store();
Assert.ok(
@ -338,7 +391,7 @@ add_task(async function test_sync_access_unenroll() {
await store.init();
store.addExperiment(experiment);
store.addEnrollment(experiment);
store.updateExperiment("foo", { active: false });
store = ExperimentFakes.store();
@ -360,8 +413,8 @@ add_task(async function test_sync_access_unenroll_2() {
await store.init();
store.addExperiment(experiment1);
store.addExperiment(experiment2);
store.addEnrollment(experiment1);
store.addEnrollment(experiment2);
Assert.equal(store.getAll().length, 2, "2/2 experiments");
@ -402,6 +455,105 @@ add_task(async function test_sync_access_unenroll_2() {
);
});
add_task(async function test_getRolloutForFeature_fromStore() {
const store = ExperimentFakes.store();
const rollout = ExperimentFakes.rollout("foo");
await store.init();
store.addEnrollment(rollout);
Assert.deepEqual(
store.getRolloutForFeature(rollout.featureIds[0]),
rollout,
"Should return back the same rollout"
);
});
add_task(async function test_getRolloutForFeature_fromSyncCache() {
let store = ExperimentFakes.store();
const rollout = ExperimentFakes.rollout("foo", {
branch: {
slug: "early-startup",
features: [{ featureId: "aboutwelcome", value: { enabled: true } }],
},
});
let updatePromise = new Promise(resolve =>
store.on(`update:${rollout.slug}`, resolve)
);
await store.init();
store.addEnrollment(rollout);
await updatePromise;
// New uninitialized store will return data from sync cache
// before init
store = ExperimentFakes.store();
Assert.ok(
Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome`),
"Sync cache is set"
);
Assert.equal(
store.getRolloutForFeature(rollout.featureIds[0]).slug,
rollout.slug,
"Should return back the same rollout"
);
Assert.deepEqual(
store.getRolloutForFeature(rollout.featureIds[0]).branch.feature,
rollout.branch.features[0],
"Should return back the same feature"
);
cleanupStorePrefCache();
});
add_task(async function test_remoteRollout() {
let store = ExperimentFakes.store();
const rollout = ExperimentFakes.rollout("foo", {
branch: {
slug: "early-startup",
features: [{ featureId: "aboutwelcome", value: { enabled: true } }],
},
});
let featureUpdateStub = sinon.stub();
let updatePromise = new Promise(resolve =>
store.on(`update:${rollout.slug}`, resolve)
);
store.on("update:aboutwelcome", featureUpdateStub);
await store.init();
store.addEnrollment(rollout);
await updatePromise;
Assert.ok(
Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome`),
"Sync cache is set"
);
updatePromise = new Promise(resolve =>
store.on(`update:${rollout.slug}`, resolve)
);
store.updateExperiment(rollout.slug, { active: false });
// wait for it to be removed
await updatePromise;
Assert.ok(featureUpdateStub.calledTwice, "Called for add and remove");
Assert.ok(
store.get(rollout.slug),
"Rollout is still in the store just not active"
);
Assert.ok(
!store.getRolloutForFeature("aboutwelcome"),
"Feature rollout should not exist"
);
Assert.ok(
!Services.prefs.getStringPref(
`${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome`,
""
),
"Sync cache is cleared"
);
});
add_task(async function test_syncDataStore_setDefault() {
cleanupStorePrefCache();
const store = ExperimentFakes.store();
@ -417,7 +569,12 @@ add_task(async function test_syncDataStore_setDefault() {
"Pref is empty"
);
store.updateRemoteConfigs("aboutwelcome", { remote: true });
let rollout = ExperimentFakes.rollout("foo", {
features: [
{ featureId: "aboutwelcome", enabled: true, value: { remote: true } },
],
});
store.addEnrollment(rollout);
Assert.ok(
Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome`),
@ -430,110 +587,62 @@ add_task(async function test_syncDataStore_setDefault() {
add_task(async function test_syncDataStore_getDefault() {
cleanupStorePrefCache();
const store = ExperimentFakes.store();
const rollout = ExperimentFakes.rollout("aboutwelcome-slug", {
branch: {
features: [
{
featureId: "aboutwelcome",
value: { remote: true },
},
],
},
});
Services.prefs.setStringPref(
`${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome`,
JSON.stringify({ remote: true })
await store.init();
await store.addEnrollment(rollout);
Assert.ok(
Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome`)
);
let data = store.getRemoteConfig("aboutwelcome");
let restoredRollout = store.getRolloutForFeature("aboutwelcome");
Assert.ok(data.remote, "Restore data from pref");
Assert.ok(restoredRollout);
Assert.ok(
restoredRollout.branch.features[0].value.remote,
"Restore data from pref"
);
cleanupStorePrefCache();
});
add_task(async function test_updateRemoteConfigs() {
add_task(async function test_addEnrollment_rollout() {
const sandbox = sinon.createSandbox();
const store = ExperimentFakes.store();
const stub = sandbox.stub();
const value = { bar: true };
let rollout = ExperimentFakes.rollout("foo", {
features: [{ featureId: "aboutwelcome", enabled: true, value }],
});
store._onFeatureUpdate("featureId", stub);
store._onFeatureUpdate("aboutwelcome", stub);
await store.init();
store.updateRemoteConfigs("featureId", value);
store.addEnrollment(rollout);
Assert.deepEqual(
store.getRemoteConfig("featureId"),
value,
store.getRolloutForFeature("aboutwelcome"),
rollout,
"should return the stored value"
);
Assert.equal(stub.callCount, 1, "Called once on update");
Assert.equal(
stub.firstCall.args[1],
"remote-defaults-update",
"rollout-updated",
"Called for correct reason"
);
});
add_task(async function test_finalizaRemoteConfigs_cleanup() {
cleanupStorePrefCache();
const store = ExperimentFakes.store();
Services.prefs.setStringPref(
`${SYNC_DEFAULTS_PREF_BRANCH}unit-test-feature`,
JSON.stringify({ remote: true })
);
// We are able to sync-read data without needing to initialize the store
let data = store.getRemoteConfig("unit-test-feature");
Assert.ok(data.remote, "Restore data from pref");
// We need to initialize the store for the cleanup step
await store.init();
store.finalizeRemoteConfigs([]);
data = store.getRemoteConfig("unit-test-feature");
Assert.ok(!data, `Data was removed ${JSON.stringify(data)}`);
cleanupStorePrefCache();
});
add_task(async function test_finalizaRemoteConfigs_cleanup() {
cleanupStorePrefCache();
const store = ExperimentFakes.store();
await store.init();
store.updateRemoteConfigs("aboutwelcome", { remote: true });
let data = store.getRemoteConfig("aboutwelcome");
Assert.ok(data.remote, "Restore data from pref");
store.finalizeRemoteConfigs(["aboutwelcome"]);
data = store.getRemoteConfig("aboutwelcome");
Assert.ok(data.remote, "Data was kept");
cleanupStorePrefCache();
});
add_task(async function test_getAllExistingRemoteConfigIds() {
cleanupStorePrefCache();
const store = ExperimentFakes.store();
Services.prefs.setStringPref(
`${SYNC_DEFAULTS_PREF_BRANCH}unit-test-feature`,
JSON.stringify({ remote: true })
);
await store.init();
store.updateRemoteConfigs("aboutwelcome", { remote: true });
store.updateRemoteConfigs("unit-test-feature", { remote: true });
let data = store.getAllExistingRemoteConfigIds();
Assert.deepEqual(
data,
["aboutwelcome", "unit-test-feature"],
"Should return ids from sync pref cache and in memory store without duplication"
);
cleanupStorePrefCache();
});
add_task(async function test_storeValuePerPref_noVariables() {
const store = ExperimentFakes.store();
const experiment = ExperimentFakes.experiment("foo", {
@ -551,7 +660,7 @@ add_task(async function test_storeValuePerPref_noVariables() {
});
await store.init();
store.addExperiment(experiment);
store.addEnrollment(experiment);
let branch = Services.prefs.getBranch(`${SYNC_DATA_PREF_BRANCH}purple.`);
@ -586,7 +695,7 @@ add_task(async function test_storeValuePerPref_withVariables() {
});
await store.init();
store.addExperiment(experiment);
store.addEnrollment(experiment);
let branch = Services.prefs.getBranch(`${SYNC_DATA_PREF_BRANCH}purple.`);
@ -624,7 +733,7 @@ add_task(async function test_storeValuePerPref_returnsSameValue() {
});
await store.init();
store.addExperiment(experiment);
store.addEnrollment(experiment);
let branch = Services.prefs.getBranch(`${SYNC_DATA_PREF_BRANCH}purple.`);
store = ExperimentFakes.store();
@ -680,7 +789,7 @@ add_task(async function test_storeValuePerPref_returnsSameValue_allTypes() {
});
await store.init();
store.addExperiment(experiment);
store.addEnrollment(experiment);
let branch = Services.prefs.getBranch(`${SYNC_DATA_PREF_BRANCH}purple.`);
store = ExperimentFakes.store();

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

@ -7,10 +7,7 @@ const { ExperimentManager } = ChromeUtils.import(
"resource://nimbus/lib/ExperimentManager.jsm"
);
const {
RemoteSettingsExperimentLoader,
RemoteDefaultsLoader,
} = ChromeUtils.import(
const { RemoteSettingsExperimentLoader } = ChromeUtils.import(
"resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm"
);
@ -54,7 +51,6 @@ add_task(async function test_init() {
const loader = ExperimentFakes.rsLoader();
sinon.stub(loader, "setTimer");
sinon.stub(loader, "updateRecipes").resolves();
sinon.stub(RemoteDefaultsLoader, "syncRemoteDefaults");
Services.prefs.setBoolPref(ENABLED_PREF, false);
await loader.init();
@ -68,10 +64,6 @@ add_task(async function test_init() {
await loader.init();
ok(loader.setTimer.calledOnce, "should call .setTimer");
ok(loader.updateRecipes.calledOnce, "should call .updatpickeRecipes");
ok(
RemoteDefaultsLoader.syncRemoteDefaults,
"initialized remote defaults loader"
);
});
add_task(async function test_init_with_opt_in() {

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

@ -3,15 +3,6 @@
const { ExperimentFakes } = ChromeUtils.import(
"resource://testing-common/NimbusTestUtils.jsm"
);
const { CleanupManager } = ChromeUtils.import(
"resource://normandy/lib/CleanupManager.jsm"
);
const { ExperimentManager } = ChromeUtils.import(
"resource://nimbus/lib/ExperimentManager.jsm"
);
const { RemoteSettingsExperimentLoader } = ChromeUtils.import(
"resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm"
);
const { FirstStartup } = ChromeUtils.import(
"resource://gre/modules/FirstStartup.jsm"
);

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

@ -40,22 +40,6 @@ with_sharedDataMap(async function test_set_notify({ instance, sandbox }) {
Assert.equal(updateStub.firstCall.args[1], "bar", "Update event sent value");
});
with_sharedDataMap(async function test_setNonPersistent_notify({
instance,
sandbox,
}) {
await instance.init();
let updateStub = sandbox.stub();
instance.on("parent-store-update:foo", updateStub);
instance.setNonPersistent("foo", "bar");
Assert.equal(updateStub.callCount, 1, "Update event sent");
Assert.equal(updateStub.firstCall.args[1], "bar", "Update event sent value");
Assert.equal(instance.get("foo"), "bar");
Assert.ok(!instance._data.foo, "Not in the persistent store");
});
with_sharedDataMap(async function test_set_child_notify({ instance, sandbox }) {
await instance.init();
@ -183,33 +167,6 @@ with_sharedDataMap(async function test_parentChildSync_async({
);
});
with_sharedDataMap(async function test_parentChildSync_nonPersistent_async({
instance: parentInstance,
sandbox,
}) {
const childInstance = new SharedDataMap("xpcshell", {
path: PATH,
isParent: false,
});
await parentInstance.init();
parentInstance.setNonPersistent("foo", { bar: 1 });
await parentInstance.ready();
await childInstance.ready();
await TestUtils.waitForCondition(
() => childInstance.get("foo"),
"Wait for child to sync"
);
Assert.deepEqual(
childInstance.get("foo"),
parentInstance.get("foo"),
"Parent and child should be in sync"
);
});
with_sharedDataMap(async function test_earlyChildSync({
instance: parentInstance,
sandbox,
@ -236,16 +193,6 @@ with_sharedDataMap(async function test_earlyChildSync({
);
});
with_sharedDataMap(async function test_set_notify({ instance, sandbox }) {
await instance.init();
Assert.ok(!instance.hasRemoteDefaultsReady(), "False on init");
instance.setNonPersistent("__REMOTE_DEFAULTS", { foo: 1 });
Assert.ok(instance.hasRemoteDefaultsReady(), "Has 1 entry");
});
with_sharedDataMap(async function test_updateStoreData({ instance, sandbox }) {
await instance.init();

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

@ -36,6 +36,8 @@ XPCOMUtils.defineLazyGetter(this, "gStringBundle", function() {
);
});
const NIMBUS_DEBUG_PREF = "nimbus.debug";
/**
* Listen for DOM events bubbling up from the about:studies page, and perform
* privileged actions in response to them. If we need to do anything that the
@ -100,6 +102,12 @@ class ShieldFrameChild extends JSWindowActorChild {
studiesEnabled
);
break;
case "GetRemoteValue:DebugModeOn":
this.triggerPageCallback(
"ReceiveRemoteValue:DebugModeOn",
Services.prefs.getBoolPref(NIMBUS_DEBUG_PREF)
);
break;
case "NavigateToDataPreferences":
this.sendAsyncMessage("Shield:OpenDataPreferences");
break;

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

@ -46,6 +46,7 @@ class AboutStudies extends React.Component {
ShieldLearnMoreHref: "learnMoreHref",
StudiesEnabled: "studiesEnabled",
ShieldTranslations: "translations",
DebugModeOn: "debugMode",
};
this.state = {};
@ -104,6 +105,7 @@ class AboutStudies extends React.Component {
prefStudies,
experiments,
optInMessage,
debugMode,
} = this.state;
// Wait for all values to be loaded before rendering. Some of the values may
// be falsey, so an explicit null check is needed.
@ -116,7 +118,13 @@ class AboutStudies extends React.Component {
{ className: "about-studies-container main-content" },
r(WhatsThisBox, { translations, learnMoreHref, studiesEnabled }),
optInMessage && r(OptInBox, optInMessage),
r(StudyList, { translations, addonStudies, prefStudies, experiments })
r(StudyList, {
translations,
addonStudies,
prefStudies,
experiments,
debugMode,
})
);
}
}
@ -183,7 +191,13 @@ function OptInBox({ error, message }) {
*/
class StudyList extends React.Component {
render() {
const { addonStudies, prefStudies, translations, experiments } = this.props;
const {
addonStudies,
prefStudies,
translations,
experiments,
debugMode,
} = this.props;
if (!addonStudies.length && !prefStudies.length && !experiments.length) {
return r("p", { className: "study-list-info" }, translations.noStudies);
@ -222,7 +236,7 @@ class StudyList extends React.Component {
type: study.experimentType,
sortDate: new Date(study.lastSeen),
});
if (!study.active) {
if (!study.active && !study.isRollout) {
inactiveStudies.push(clonedStudy);
} else {
activeStudies.push(clonedStudy);
@ -246,15 +260,12 @@ class StudyList extends React.Component {
translations,
});
}
if (
study.type === "nimbus" ||
// Backwards compatibility with old recipes
study.type === "messaging_experiment"
) {
if (study.type === "nimbus" || study.type === "rollout") {
return r(MessagingSystemListItem, {
key: study.slug,
study,
translations,
debugMode,
});
}
if (study.type === "pref") {
@ -321,10 +332,13 @@ class MessagingSystemListItem extends React.Component {
}
render() {
const { study, translations } = this.props;
const { study, translations, debugMode } = this.props;
const userFacingName = study.userFacingName || study.slug;
const userFacingDescription =
study.userFacingDescription || "Nimbus experiment.";
if (study.isRollout && !debugMode) {
return null;
}
return r(
"li",
{

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

@ -644,7 +644,7 @@ decorate_task(
}
);
add_task(async function test_nimbus_about_studies() {
add_task(async function test_nimbus_about_studies_experiment() {
const recipe = ExperimentFakes.recipe("about-studies-foo");
await ExperimentManager.enroll(recipe);
await BrowserTestUtils.withNewTab(
@ -689,52 +689,61 @@ add_task(async function test_nimbus_about_studies() {
Assert.equal(ExperimentManager.store.getAll().length, 0, "Cleanup done");
});
add_task(async function test_nimbus_backwards_compatibility() {
const recipe = ExperimentFakes.recipe("about-studies-foo");
await ExperimentManager.enroll({
experimentType: "messaging_experiment",
add_task(async function test_nimbus_about_studies_rollout() {
let recipe = ExperimentFakes.recipe("test_nimbus_about_studies_rollout");
let rollout = {
...recipe,
});
branches: [recipe.branches[0]],
isRollout: true,
};
await ExperimentManager.enroll(rollout);
await BrowserTestUtils.withNewTab(
{ gBrowser, url: "about:studies" },
async browser => {
const name = await SpecialPowers.spawn(browser, [], async () => {
const studyCount = await SpecialPowers.spawn(browser, [], async () => {
await ContentTaskUtils.waitForCondition(
() => content.document.querySelector("#shield-studies-learn-more"),
"waiting for page/experiment to load"
);
return content.document.querySelectorAll(".study-name").length;
});
// Make sure strings are properly shown
Assert.equal(studyCount, 0, "Rollout not loaded in non-debug mode");
}
);
Services.prefs.setBoolPref("nimbus.debug", true);
await BrowserTestUtils.withNewTab(
{ gBrowser, url: "about:studies" },
async browser => {
const studyName = await SpecialPowers.spawn(browser, [], async () => {
await ContentTaskUtils.waitForCondition(
() => content.document.querySelector(".nimbus .remove-button"),
"waiting for page/experiment to load"
);
return content.document.querySelector(".study-name").innerText;
return content.document.querySelector(".study-header").innerText;
});
// Make sure strings are properly shown
Assert.equal(
name,
recipe.userFacingName,
"Correct active experiment name"
);
Assert.ok(studyName.includes("Active"), "Rollout loaded in debug mode");
}
);
ExperimentManager.unenroll(recipe.slug);
await BrowserTestUtils.withNewTab(
{ gBrowser, url: "about:studies" },
async browser => {
const name = await SpecialPowers.spawn(browser, [], async () => {
content.document.querySelector(".remove-button").click();
await ContentTaskUtils.waitForCondition(
() => content.document.querySelector(".nimbus.disabled"),
"waiting for experiment to become disabled"
);
return content.document.querySelector(".study-name").innerText;
return content.document.querySelector(".study-header").innerText;
});
// Make sure strings are properly shown
Assert.equal(
name,
recipe.userFacingName,
"Correct disabled experiment name"
);
Assert.ok(name.includes("Complete"), "Rollout was removed");
}
);
// Cleanup for multiple test runs
ExperimentManager.store._deleteForTests(recipe.slug);
Assert.equal(ExperimentManager.store.getAll().length, 0, "Cleanup done");
ExperimentManager.store._deleteForTests(rollout.slug);
Services.prefs.clearUserPref("nimbus.debug");
});
add_task(async function test_getStudiesEnabled() {

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

@ -1183,7 +1183,7 @@ var snapshotFormatters = {
addonStudies,
prefRollouts,
nimbusExperiments,
remoteConfigs,
nimbusRollouts,
} = data;
$.append(
$("remote-features-tbody"),
@ -1197,14 +1197,13 @@ var snapshotFormatters = {
$.append(
$("remote-features-tbody"),
remoteConfigs.map(({ featureId, slug }) =>
nimbusRollouts.map(({ userFacingName, branch }) =>
$.new("tr", [
$.new("td", [document.createTextNode(featureId)]),
$.new("td", [document.createTextNode(`(${slug})`)]),
$.new("td", [document.createTextNode(userFacingName)]),
$.new("td", [document.createTextNode(`(${branch.slug})`)]),
])
)
);
$.append(
$("remote-experiments-tbody"),
[addonStudies, prefStudies, nimbusExperiments]

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

@ -864,7 +864,7 @@ var dataProviders = {
prefRollouts,
prefStudies,
nimbusExperiments,
remoteConfigs,
nimbusRollouts,
] = await Promise.all(
[
NormandyAddonStudies.getAllActive(),
@ -875,7 +875,7 @@ var dataProviders = {
.then(() => ExperimentManager.store.getAllActive()),
ExperimentManager.store
.ready()
.then(() => ExperimentManager.store.getAllRemoteConfigs()),
.then(() => ExperimentManager.store.getAllRollouts()),
].map(promise =>
promise
.catch(error => {
@ -891,7 +891,7 @@ var dataProviders = {
prefRollouts,
prefStudies,
nimbusExperiments,
remoteConfigs,
nimbusRollouts,
});
},
};