зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1738286 - New schema and enrollment flow for rollouts r=k88hudson
Differential Revision: https://phabricator.services.mozilla.com/D129835
This commit is contained in:
Родитель
8941bcbb1e
Коммит
ef300c52d4
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
Загрузка…
Ссылка в новой задаче