зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1726190 - Implement multi-feature enrollment support for Nimbus r=k88hudson
Differential Revision: https://phabricator.services.mozilla.com/D123139
This commit is contained in:
Родитель
b2955e8a9d
Коммит
07703bd30d
|
@ -57,6 +57,19 @@ function parseJSON(value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function featuresCompat(branch) {
|
||||||
|
if (!branch) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
let { features } = branch;
|
||||||
|
// In <=v1.5.0 of the Nimbus API, experiments had single feature
|
||||||
|
if (!features) {
|
||||||
|
features = [branch.feature];
|
||||||
|
}
|
||||||
|
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
const ExperimentAPI = {
|
const ExperimentAPI = {
|
||||||
/**
|
/**
|
||||||
* @returns {Promise} Resolves when the API has synchronized to the main store
|
* @returns {Promise} Resolves when the API has synchronized to the main store
|
||||||
|
@ -405,9 +418,13 @@ class _ExperimentFeature {
|
||||||
isEnabled({ defaultValue = null } = {}) {
|
isEnabled({ defaultValue = null } = {}) {
|
||||||
const branch = ExperimentAPI.activateBranch({ featureId: this.featureId });
|
const branch = ExperimentAPI.activateBranch({ featureId: this.featureId });
|
||||||
|
|
||||||
|
let feature = featuresCompat(branch).find(
|
||||||
|
({ featureId }) => featureId === this.featureId
|
||||||
|
);
|
||||||
|
|
||||||
// First, try to return an experiment value if it exists.
|
// First, try to return an experiment value if it exists.
|
||||||
if (isBooleanValueDefined(branch?.feature.enabled)) {
|
if (isBooleanValueDefined(feature?.enabled)) {
|
||||||
return branch.feature.enabled;
|
return feature.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBooleanValueDefined(this.getRemoteConfig()?.enabled)) {
|
if (isBooleanValueDefined(this.getRemoteConfig()?.enabled)) {
|
||||||
|
@ -436,12 +453,15 @@ class _ExperimentFeature {
|
||||||
// Any user pref will override any other configuration
|
// Any user pref will override any other configuration
|
||||||
let userPrefs = this._getUserPrefsValues();
|
let userPrefs = this._getUserPrefsValues();
|
||||||
const branch = ExperimentAPI.activateBranch({ featureId: this.featureId });
|
const branch = ExperimentAPI.activateBranch({ featureId: this.featureId });
|
||||||
|
const featureValue = featuresCompat(branch).find(
|
||||||
|
({ featureId }) => featureId === this.featureId
|
||||||
|
)?.value;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...this.prefGetters,
|
...this.prefGetters,
|
||||||
...defaultValues,
|
...defaultValues,
|
||||||
...this.getRemoteConfig()?.variables,
|
...this.getRemoteConfig()?.variables,
|
||||||
...(branch?.feature?.value || null),
|
...(featureValue || null),
|
||||||
...userPrefs,
|
...userPrefs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -465,9 +485,12 @@ class _ExperimentFeature {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next, check if an experiment is defined
|
// Next, check if an experiment is defined
|
||||||
const experimentValue = ExperimentAPI.activateBranch({
|
const branch = ExperimentAPI.activateBranch({
|
||||||
featureId: this.featureId,
|
featureId: this.featureId,
|
||||||
})?.feature?.value?.[variable];
|
});
|
||||||
|
const experimentValue = featuresCompat(branch).find(
|
||||||
|
({ featureId }) => featureId === this.featureId
|
||||||
|
)?.value?.[variable];
|
||||||
|
|
||||||
if (typeof experimentValue !== "undefined") {
|
if (typeof experimentValue !== "undefined") {
|
||||||
return experimentValue;
|
return experimentValue;
|
||||||
|
|
|
@ -35,6 +35,19 @@ const TELEMETRY_DEFAULT_EXPERIMENT_TYPE = "nimbus";
|
||||||
|
|
||||||
const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled";
|
const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled";
|
||||||
|
|
||||||
|
function featuresCompat(branch) {
|
||||||
|
if (!branch || (!branch.feature && !branch.features)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
let { features } = branch;
|
||||||
|
// In <=v1.5.0 of the Nimbus API, experiments had single feature
|
||||||
|
if (!features) {
|
||||||
|
features = [branch.feature];
|
||||||
|
}
|
||||||
|
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A module for processes Experiment recipes, choosing and storing enrollment state,
|
* A module for processes Experiment recipes, choosing and storing enrollment state,
|
||||||
* and sending experiment-related Telemetry.
|
* and sending experiment-related Telemetry.
|
||||||
|
@ -184,13 +197,9 @@ class _ExperimentManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
const branch = await this.chooseBranch(slug, branches);
|
const branch = await this.chooseBranch(slug, branches);
|
||||||
|
const features = featuresCompat(branch);
|
||||||
if (
|
for (let feature of features) {
|
||||||
this.store.hasExperimentForFeature(
|
if (this.store.hasExperimentForFeature(feature?.featureId)) {
|
||||||
// Extract out only the feature names from the branch
|
|
||||||
branch.feature?.featureId
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
log.debug(
|
log.debug(
|
||||||
`Skipping enrollment for "${slug}" because there is an existing experiment for its feature.`
|
`Skipping enrollment for "${slug}" because there is an existing experiment for its feature.`
|
||||||
);
|
);
|
||||||
|
@ -198,6 +207,7 @@ class _ExperimentManager {
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return this._enroll(recipe, branch, source);
|
return this._enroll(recipe, branch, source);
|
||||||
}
|
}
|
||||||
|
@ -250,16 +260,17 @@ class _ExperimentManager {
|
||||||
* If the experiment has the same slug after unenrollment adding it to the
|
* If the experiment has the same slug after unenrollment adding it to the
|
||||||
* store will overwrite the initial experiment.
|
* store will overwrite the initial experiment.
|
||||||
*/
|
*/
|
||||||
let experiment = this.store.getExperimentForFeature(
|
const features = featuresCompat(branch);
|
||||||
branch.feature?.featureId
|
for (let feature of features) {
|
||||||
);
|
let experiment = this.store.getExperimentForFeature(feature?.featureId);
|
||||||
if (experiment) {
|
if (experiment) {
|
||||||
log.debug(
|
log.debug(
|
||||||
`Existing experiment found for the same feature ${branch?.feature.featureId}, unenrolling.`
|
`Existing experiment found for the same feature ${feature.featureId}, unenrolling.`
|
||||||
);
|
);
|
||||||
|
|
||||||
this.unenroll(experiment.slug, source);
|
this.unenroll(experiment.slug, source);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
recipe.userFacingName = `${recipe.userFacingName} - Forced enrollment`;
|
recipe.userFacingName = `${recipe.userFacingName} - Forced enrollment`;
|
||||||
|
|
||||||
|
|
|
@ -34,14 +34,6 @@ let tryJSONParse = data => {
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
XPCOMUtils.defineLazyGetter(this, "syncDataStore", () => {
|
XPCOMUtils.defineLazyGetter(this, "syncDataStore", () => {
|
||||||
// Pref name changed in bug 1691516 and we want to clear the old pref for users
|
|
||||||
const previousPrefName = "messaging-system.syncdatastore.data";
|
|
||||||
try {
|
|
||||||
if (IS_MAIN_PROCESS) {
|
|
||||||
Services.prefs.clearUserPref(previousPrefName);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
let experimentsPrefBranch = Services.prefs.getBranch(SYNC_DATA_PREF_BRANCH);
|
let experimentsPrefBranch = Services.prefs.getBranch(SYNC_DATA_PREF_BRANCH);
|
||||||
let defaultsPrefBranch = Services.prefs.getBranch(SYNC_DEFAULTS_PREF_BRANCH);
|
let defaultsPrefBranch = Services.prefs.getBranch(SYNC_DEFAULTS_PREF_BRANCH);
|
||||||
return {
|
return {
|
||||||
|
@ -205,6 +197,31 @@ XPCOMUtils.defineLazyGetter(this, "syncDataStore", () => {
|
||||||
|
|
||||||
const DEFAULT_STORE_ID = "ExperimentStoreData";
|
const DEFAULT_STORE_ID = "ExperimentStoreData";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all feature ids associated with the branch provided.
|
||||||
|
* Fallback for when `featureIds` was not persisted to disk. Can be removed
|
||||||
|
* after bug 1725240 has reached release.
|
||||||
|
*
|
||||||
|
* @param {Branch} branch
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function getAllBranchFeatureIds(branch) {
|
||||||
|
return featuresCompat(branch).map(f => f.featureId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function featuresCompat(branch) {
|
||||||
|
if (!branch || (!branch.feature && !branch.features)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
let { features } = branch;
|
||||||
|
// In <=v1.5.0 of the Nimbus API, experiments had single feature
|
||||||
|
if (!features) {
|
||||||
|
features = [branch.feature];
|
||||||
|
}
|
||||||
|
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
class ExperimentStore extends SharedDataMap {
|
class ExperimentStore extends SharedDataMap {
|
||||||
static SYNC_DATA_PREF_BRANCH = SYNC_DATA_PREF_BRANCH;
|
static SYNC_DATA_PREF_BRANCH = SYNC_DATA_PREF_BRANCH;
|
||||||
static SYNC_DEFAULTS_PREF_BRANCH = SYNC_DEFAULTS_PREF_BRANCH;
|
static SYNC_DEFAULTS_PREF_BRANCH = SYNC_DEFAULTS_PREF_BRANCH;
|
||||||
|
@ -216,13 +233,10 @@ class ExperimentStore extends SharedDataMap {
|
||||||
async init() {
|
async init() {
|
||||||
await super.init();
|
await super.init();
|
||||||
|
|
||||||
this.getAllActive().forEach(({ branch }) => {
|
this.getAllActive().forEach(({ branch, featureIds }) => {
|
||||||
if (branch?.feature?.featureId) {
|
(featureIds || getAllBranchFeatureIds(branch)).forEach(featureId =>
|
||||||
this._emitFeatureUpdate(
|
this._emitFeatureUpdate(featureId, "feature-experiment-loaded")
|
||||||
branch.feature.featureId,
|
|
||||||
"feature-experiment-loaded"
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Services.tm.idleDispatchToMainThread(() => this._cleanupOldRecipes());
|
Services.tm.idleDispatchToMainThread(() => this._cleanupOldRecipes());
|
||||||
|
@ -243,7 +257,7 @@ class ExperimentStore extends SharedDataMap {
|
||||||
experiment =>
|
experiment =>
|
||||||
experiment.featureIds?.includes(featureId) ||
|
experiment.featureIds?.includes(featureId) ||
|
||||||
// Supports <v1.3.0, which was when .featureIds was added
|
// Supports <v1.3.0, which was when .featureIds was added
|
||||||
experiment.branch?.feature?.featureId === featureId
|
getAllBranchFeatureIds(experiment.branch).includes(featureId)
|
||||||
// Default to the pref store if data is not yet ready
|
// Default to the pref store if data is not yet ready
|
||||||
) || syncDataStore.get(featureId)
|
) || syncDataStore.get(featureId)
|
||||||
);
|
);
|
||||||
|
@ -305,13 +319,12 @@ class ExperimentStore extends SharedDataMap {
|
||||||
|
|
||||||
_emitExperimentUpdates(experiment) {
|
_emitExperimentUpdates(experiment) {
|
||||||
this.emit(`update:${experiment.slug}`, experiment);
|
this.emit(`update:${experiment.slug}`, experiment);
|
||||||
if (experiment.branch.feature) {
|
(
|
||||||
this.emit(`update:${experiment.branch.feature.featureId}`, experiment);
|
experiment.featureIds || getAllBranchFeatureIds(experiment.branch)
|
||||||
this._emitFeatureUpdate(
|
).forEach(featureId => {
|
||||||
experiment.branch.feature.featureId,
|
this.emit(`update:${featureId}`, experiment);
|
||||||
"experiment-updated"
|
this._emitFeatureUpdate(featureId, "experiment-updated");
|
||||||
);
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_emitFeatureUpdate(featureId, reason) {
|
_emitFeatureUpdate(featureId, reason) {
|
||||||
|
@ -330,16 +343,26 @@ class ExperimentStore extends SharedDataMap {
|
||||||
* @param {Enrollment} experiment
|
* @param {Enrollment} experiment
|
||||||
*/
|
*/
|
||||||
_updateSyncStore(experiment) {
|
_updateSyncStore(experiment) {
|
||||||
let featureId = experiment.branch.feature?.featureId;
|
let features = featuresCompat(experiment.branch);
|
||||||
|
for (let feature of features) {
|
||||||
if (
|
if (
|
||||||
FeatureManifest[featureId]?.isEarlyStartup ||
|
FeatureManifest[feature.featureId]?.isEarlyStartup ||
|
||||||
experiment.branch.feature?.isEarlyStartup
|
feature.isEarlyStartup
|
||||||
) {
|
) {
|
||||||
if (!experiment.active) {
|
if (!experiment.active) {
|
||||||
// Remove experiments on un-enroll, no need to check if it exists
|
// Remove experiments on un-enroll, no need to check if it exists
|
||||||
syncDataStore.delete(featureId);
|
syncDataStore.delete(feature.featureId);
|
||||||
} else {
|
} else {
|
||||||
syncDataStore.set(featureId, experiment);
|
syncDataStore.set(feature.featureId, {
|
||||||
|
...experiment,
|
||||||
|
branch: {
|
||||||
|
...experiment.branch,
|
||||||
|
feature,
|
||||||
|
// Only store the early startup feature
|
||||||
|
features: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,9 @@
|
||||||
"branch": {
|
"branch": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"feature": {
|
"features": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"featureId": {
|
"featureId": {
|
||||||
|
@ -38,8 +40,9 @@
|
||||||
"required": ["featureId", "value"],
|
"required": ["featureId", "value"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["feature"]
|
"required": ["features"]
|
||||||
},
|
},
|
||||||
"active": {
|
"active": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
|
@ -87,7 +90,8 @@
|
||||||
"experimentType",
|
"experimentType",
|
||||||
"source",
|
"source",
|
||||||
"userFacingName",
|
"userFacingName",
|
||||||
"userFacingDescription"
|
"userFacingDescription",
|
||||||
|
"featureIds"
|
||||||
],
|
],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "The experiment definition accessible to:\n1. The Nimbus SDK via Remote Settings\n2. Jetstream via the Experimenter API"
|
"description": "The experiment definition accessible to:\n1. The Nimbus SDK via Remote Settings\n2. Jetstream via the Experimenter API"
|
||||||
|
|
|
@ -85,7 +85,9 @@
|
||||||
"description": "Relative ratio of population for the branch (e.g. if branch A=1 and branch B=3,\nbranch A would get 25% of the population)",
|
"description": "Relative ratio of population for the branch (e.g. if branch A=1 and branch B=3,\nbranch A would get 25% of the population)",
|
||||||
"default": 1
|
"default": 1
|
||||||
},
|
},
|
||||||
"feature": {
|
"features": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"featureId": {
|
"featureId": {
|
||||||
|
@ -108,6 +110,7 @@
|
||||||
"required": ["featureId", "value"],
|
"required": ["featureId", "value"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["slug", "ratio"],
|
"required": ["slug", "ratio"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
@ -143,6 +146,11 @@
|
||||||
"filter_expression": {
|
"filter_expression": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "This is NOT used by Nimbus, but has special functionality in Remote Settings.\nSee https://remote-settings.readthedocs.io/en/latest/target-filters.html#how"
|
"description": "This is NOT used by Nimbus, but has special functionality in Remote Settings.\nSee https://remote-settings.readthedocs.io/en/latest/target-filters.html#how"
|
||||||
|
},
|
||||||
|
"featureIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": [{ "type": "string" }],
|
||||||
|
"description": "Array of strings corresponding to the branch features in the enrollment."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
@ -158,7 +166,8 @@
|
||||||
"startDate",
|
"startDate",
|
||||||
"endDate",
|
"endDate",
|
||||||
"proposedEnrollment",
|
"proposedEnrollment",
|
||||||
"referenceBranch"
|
"referenceBranch",
|
||||||
|
"featureIds"
|
||||||
],
|
],
|
||||||
"additionalProperties": true,
|
"additionalProperties": true,
|
||||||
"description": "The experiment definition accessible to:\n1. The Nimbus SDK via Remote Settings\n2. Jetstream via the Experimenter API"
|
"description": "The experiment definition accessible to:\n1. The Nimbus SDK via Remote Settings\n2. Jetstream via the Experimenter API"
|
||||||
|
|
|
@ -53,7 +53,8 @@ const ExperimentTestUtils = {
|
||||||
},
|
},
|
||||||
|
|
||||||
_validateFeatureValueEnum({ branch }) {
|
_validateFeatureValueEnum({ branch }) {
|
||||||
let { feature } = branch;
|
let { features } = branch;
|
||||||
|
for (let feature of features) {
|
||||||
// If we're not using a real feature skip this check
|
// If we're not using a real feature skip this check
|
||||||
if (!FeatureManifest[feature.featureId]) {
|
if (!FeatureManifest[feature.featureId]) {
|
||||||
return true;
|
return true;
|
||||||
|
@ -73,7 +74,7 @@ const ExperimentTestUtils = {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -100,6 +101,12 @@ const ExperimentTestUtils = {
|
||||||
)
|
)
|
||||||
).NimbusExperiment;
|
).NimbusExperiment;
|
||||||
|
|
||||||
|
// We still have single feature experiment recipes for backwards
|
||||||
|
// compatibility testing but we don't do schema validation
|
||||||
|
if (!enrollment.branch.features && enrollment.branch.feature) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
this._validateFeatureValueEnum(enrollment) &&
|
this._validateFeatureValueEnum(enrollment) &&
|
||||||
this._validator(
|
this._validator(
|
||||||
|
@ -184,7 +191,7 @@ const ExperimentFakes = {
|
||||||
{
|
{
|
||||||
slug: "control",
|
slug: "control",
|
||||||
ratio: 1,
|
ratio: 1,
|
||||||
feature: featureConfig,
|
features: [featureConfig],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
@ -268,10 +275,12 @@ const ExperimentFakes = {
|
||||||
enrollmentId: NormandyUtils.generateUuid(),
|
enrollmentId: NormandyUtils.generateUuid(),
|
||||||
branch: {
|
branch: {
|
||||||
slug: "treatment",
|
slug: "treatment",
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
featureId: "test-feature",
|
featureId: "test-feature",
|
||||||
value: { title: "hello", enabled: true },
|
value: { title: "hello", enabled: true },
|
||||||
},
|
},
|
||||||
|
],
|
||||||
...props,
|
...props,
|
||||||
},
|
},
|
||||||
source: "NimbusTestUtils",
|
source: "NimbusTestUtils",
|
||||||
|
@ -279,6 +288,9 @@ const ExperimentFakes = {
|
||||||
experimentType: "NimbusTestUtils",
|
experimentType: "NimbusTestUtils",
|
||||||
userFacingName: "NimbusTestUtils",
|
userFacingName: "NimbusTestUtils",
|
||||||
userFacingDescription: "NimbusTestUtils",
|
userFacingDescription: "NimbusTestUtils",
|
||||||
|
featureIds: props?.branch?.features?.map(f => f.featureId) || [
|
||||||
|
"test-feature",
|
||||||
|
],
|
||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -298,15 +310,17 @@ const ExperimentFakes = {
|
||||||
{
|
{
|
||||||
slug: "control",
|
slug: "control",
|
||||||
ratio: 1,
|
ratio: 1,
|
||||||
feature: { featureId: "test-feature", value: { enabled: true } },
|
features: [{ featureId: "test-feature", value: { enabled: true } }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "treatment",
|
slug: "treatment",
|
||||||
ratio: 1,
|
ratio: 1,
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
featureId: "test-feature",
|
featureId: "test-feature",
|
||||||
value: { title: "hello", enabled: true },
|
value: { title: "hello", enabled: true },
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bucketConfig: {
|
bucketConfig: {
|
||||||
|
@ -318,7 +332,9 @@ const ExperimentFakes = {
|
||||||
},
|
},
|
||||||
userFacingName: "Nimbus recipe",
|
userFacingName: "Nimbus recipe",
|
||||||
userFacingDescription: "NimbusTestUtils recipe",
|
userFacingDescription: "NimbusTestUtils recipe",
|
||||||
featureIds: ["test-feature"],
|
featureIds: props?.branches?.[0].features?.map(f => f.featureId) || [
|
||||||
|
"test-feature",
|
||||||
|
],
|
||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
@ -5,9 +5,11 @@ prefs =
|
||||||
# This turns off the update interval for fetching recipes from Remote Settings
|
# This turns off the update interval for fetching recipes from Remote Settings
|
||||||
app.normandy.run_interval_seconds=0
|
app.normandy.run_interval_seconds=0
|
||||||
|
|
||||||
|
[browser_experiment_single_feature_enrollment.js]
|
||||||
[browser_remotesettingsexperimentloader_remote_defaults.js]
|
[browser_remotesettingsexperimentloader_remote_defaults.js]
|
||||||
[browser_remotesettingsexperimentloader_force_enrollment.js]
|
[browser_remotesettingsexperimentloader_force_enrollment.js]
|
||||||
[browser_experimentstore_load.js]
|
[browser_experimentstore_load.js]
|
||||||
|
[browser_experimentstore_load_single_feature.js]
|
||||||
[browser_remotesettings_experiment_enroll.js]
|
[browser_remotesettings_experiment_enroll.js]
|
||||||
[browser_experiment_evaluate_jexl.js]
|
[browser_experiment_evaluate_jexl.js]
|
||||||
[browser_remotesettingsexperimentloader_init.js]
|
[browser_remotesettingsexperimentloader_init.js]
|
||||||
|
|
|
@ -83,14 +83,15 @@ add_task(async function test_evaluate_active_experiments_activeExperiments() {
|
||||||
branches: [
|
branches: [
|
||||||
{
|
{
|
||||||
slug: "mochitest-active-foo",
|
slug: "mochitest-active-foo",
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
featureId: "foo",
|
featureId: "foo",
|
||||||
value: null,
|
value: null,
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
active: true,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,131 @@
|
||||||
|
/* Any copyright is dedicated to the Public Domain.
|
||||||
|
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const { ExperimentFakes } = ChromeUtils.import(
|
||||||
|
"resource://testing-common/NimbusTestUtils.jsm"
|
||||||
|
);
|
||||||
|
const { ExperimentAPI, NimbusFeatures } = ChromeUtils.import(
|
||||||
|
"resource://nimbus/ExperimentAPI.jsm"
|
||||||
|
);
|
||||||
|
|
||||||
|
const SINGLE_FEATURE_RECIPE = {
|
||||||
|
appId: "firefox-desktop",
|
||||||
|
appName: "firefox_desktop",
|
||||||
|
application: "firefox-desktop",
|
||||||
|
arguments: {},
|
||||||
|
branches: [
|
||||||
|
{
|
||||||
|
feature: {
|
||||||
|
enabled: true,
|
||||||
|
featureId: "urlbar",
|
||||||
|
isEarlyStartup: true,
|
||||||
|
value: {
|
||||||
|
quickSuggestEnabled: false,
|
||||||
|
quickSuggestNonSponsoredIndex: -1,
|
||||||
|
quickSuggestShouldShowOnboardingDialog: true,
|
||||||
|
quickSuggestShowOnboardingDialogAfterNRestarts: 2,
|
||||||
|
quickSuggestSponsoredIndex: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ratio: 1,
|
||||||
|
slug: "control",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
feature: {
|
||||||
|
enabled: true,
|
||||||
|
featureId: "urlbar",
|
||||||
|
isEarlyStartup: true,
|
||||||
|
value: {
|
||||||
|
quickSuggestEnabled: true,
|
||||||
|
quickSuggestNonSponsoredIndex: -1,
|
||||||
|
quickSuggestShouldShowOnboardingDialog: false,
|
||||||
|
quickSuggestShowOnboardingDialogAfterNRestarts: 2,
|
||||||
|
quickSuggestSponsoredIndex: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ratio: 1,
|
||||||
|
slug: "treatment",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bucketConfig: {
|
||||||
|
count: 10000,
|
||||||
|
namespace: "urlbar-9",
|
||||||
|
randomizationUnit: "normandy_id",
|
||||||
|
start: 0,
|
||||||
|
total: 10000,
|
||||||
|
},
|
||||||
|
channel: "release",
|
||||||
|
endDate: null,
|
||||||
|
featureIds: ["urlbar"],
|
||||||
|
id: "firefox-suggest-history-vs-offline",
|
||||||
|
isEnrollmentPaused: false,
|
||||||
|
outcomes: [],
|
||||||
|
probeSets: [],
|
||||||
|
proposedDuration: 28,
|
||||||
|
proposedEnrollment: 7,
|
||||||
|
referenceBranch: "control",
|
||||||
|
schemaVersion: "1.5.0",
|
||||||
|
slug: "firefox-suggest-history-vs-offline",
|
||||||
|
startDate: "2021-07-21",
|
||||||
|
targeting: "true",
|
||||||
|
userFacingDescription: "Smarter suggestions in the AwesomeBar",
|
||||||
|
userFacingName: "Firefox Suggest - History vs Offline",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYNC_DATA_PREF_BRANCH = "nimbus.syncdatastore.";
|
||||||
|
|
||||||
|
add_task(async function test_TODO() {
|
||||||
|
let {
|
||||||
|
enrollmentPromise,
|
||||||
|
doExperimentCleanup,
|
||||||
|
} = ExperimentFakes.enrollmentHelper(SINGLE_FEATURE_RECIPE);
|
||||||
|
let sandbox = sinon.createSandbox();
|
||||||
|
let stub = sandbox.stub(ExperimentAPI, "recordExposureEvent");
|
||||||
|
|
||||||
|
await enrollmentPromise;
|
||||||
|
|
||||||
|
Assert.ok(
|
||||||
|
ExperimentAPI.getExperiment({ featureId: "urlbar" }),
|
||||||
|
"Should enroll in single feature experiment"
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.ok(
|
||||||
|
Services.prefs.getStringPref(`${SYNC_DATA_PREF_BRANCH}urlbar`),
|
||||||
|
"Should store early startup feature for sync access"
|
||||||
|
);
|
||||||
|
Assert.equal(
|
||||||
|
Services.prefs.getIntPref(
|
||||||
|
`${SYNC_DATA_PREF_BRANCH}urlbar.quickSuggestSponsoredIndex`
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
"Should store early startup variable for sync access"
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.equal(
|
||||||
|
NimbusFeatures.urlbar.getVariable(
|
||||||
|
"quickSuggestShowOnboardingDialogAfterNRestarts"
|
||||||
|
),
|
||||||
|
2,
|
||||||
|
"Should return value"
|
||||||
|
);
|
||||||
|
|
||||||
|
NimbusFeatures.urlbar.recordExposureEvent();
|
||||||
|
|
||||||
|
Assert.ok(stub.calledOnce, "Should be called once by urlbar");
|
||||||
|
Assert.equal(
|
||||||
|
stub.firstCall.args[0].experimentSlug,
|
||||||
|
"firefox-suggest-history-vs-offline",
|
||||||
|
"Should have expected slug"
|
||||||
|
);
|
||||||
|
Assert.equal(
|
||||||
|
stub.firstCall.args[0].featureId,
|
||||||
|
"urlbar",
|
||||||
|
"Should have expected featureId"
|
||||||
|
);
|
||||||
|
|
||||||
|
await doExperimentCleanup();
|
||||||
|
sandbox.restore();
|
||||||
|
NimbusFeatures.urlbar._sendExposureEventOnce = true;
|
||||||
|
});
|
|
@ -61,7 +61,7 @@ add_task(async function test_load_from_disk_event() {
|
||||||
const experiment = ExperimentFakes.experiment("foo", {
|
const experiment = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: { featureId: "green", enabled: true },
|
features: [{ featureId: "green", enabled: true }],
|
||||||
},
|
},
|
||||||
lastSeen: Date.now(),
|
lastSeen: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,93 @@
|
||||||
|
/* Any copyright is dedicated to the Public Domain.
|
||||||
|
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const { ExperimentStore } = ChromeUtils.import(
|
||||||
|
"resource://nimbus/lib/ExperimentStore.jsm"
|
||||||
|
);
|
||||||
|
const { ExperimentFakes } = ChromeUtils.import(
|
||||||
|
"resource://testing-common/NimbusTestUtils.jsm"
|
||||||
|
);
|
||||||
|
const { NimbusFeatures, ExperimentAPI } = ChromeUtils.import(
|
||||||
|
"resource://nimbus/ExperimentAPI.jsm"
|
||||||
|
);
|
||||||
|
ChromeUtils.defineModuleGetter(
|
||||||
|
this,
|
||||||
|
"JSONFile",
|
||||||
|
"resource://gre/modules/JSONFile.jsm"
|
||||||
|
);
|
||||||
|
|
||||||
|
const SINGLE_FEATURE_RECIPE = {
|
||||||
|
...ExperimentFakes.experiment(),
|
||||||
|
branch: {
|
||||||
|
feature: {
|
||||||
|
enabled: true,
|
||||||
|
featureId: "urlbar",
|
||||||
|
value: {
|
||||||
|
valueThatWillDefinitelyShowUp: 42,
|
||||||
|
quickSuggestNonSponsoredIndex: 2021,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ratio: 1,
|
||||||
|
slug: "control",
|
||||||
|
},
|
||||||
|
featureIds: ["urlbar"],
|
||||||
|
slug: "browser_experimentstore_load_single_feature",
|
||||||
|
userFacingDescription: "Smarter suggestions in the AwesomeBar",
|
||||||
|
userFacingName: "Firefox Suggest - History vs Offline",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPath() {
|
||||||
|
const profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;
|
||||||
|
// NOTE: If this test is failing because you have updated this path in `ExperimentStore`,
|
||||||
|
// users will lose their old experiment data. You should do something to migrate that data.
|
||||||
|
return PathUtils.join(profileDir, "ExperimentStoreData.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
add_task(async function test_load_from_disk_event() {
|
||||||
|
Services.prefs.setStringPref("messaging-system.log", "all");
|
||||||
|
const stub = sinon.stub();
|
||||||
|
const previousSession = new JSONFile({ path: getPath() });
|
||||||
|
await previousSession.load();
|
||||||
|
previousSession.data[SINGLE_FEATURE_RECIPE.slug] = SINGLE_FEATURE_RECIPE;
|
||||||
|
previousSession.saveSoon();
|
||||||
|
await previousSession.finalize();
|
||||||
|
|
||||||
|
// Create a store and expect to load data from previous session
|
||||||
|
const store = new ExperimentStore();
|
||||||
|
|
||||||
|
let apiStoreStub = sinon.stub(ExperimentAPI, "_store").get(() => store);
|
||||||
|
|
||||||
|
store._onFeatureUpdate("urlbar", stub);
|
||||||
|
|
||||||
|
await store.init();
|
||||||
|
await store.ready();
|
||||||
|
|
||||||
|
await TestUtils.waitForCondition(() => stub.called, "Stub was called");
|
||||||
|
Assert.ok(
|
||||||
|
store.get(SINGLE_FEATURE_RECIPE.slug)?.slug,
|
||||||
|
"Experiment is loaded from disk"
|
||||||
|
);
|
||||||
|
Assert.ok(stub.firstCall.args[1], "feature-experiment-loaded");
|
||||||
|
Assert.equal(
|
||||||
|
NimbusFeatures.urlbar.getAllVariables().valueThatWillDefinitelyShowUp,
|
||||||
|
SINGLE_FEATURE_RECIPE.branch.feature.value.valueThatWillDefinitelyShowUp,
|
||||||
|
"Should match getAllVariables"
|
||||||
|
);
|
||||||
|
Assert.equal(
|
||||||
|
NimbusFeatures.urlbar.getVariable("quickSuggestNonSponsoredIndex"),
|
||||||
|
SINGLE_FEATURE_RECIPE.branch.feature.value.quickSuggestNonSponsoredIndex,
|
||||||
|
"Should match getVariable"
|
||||||
|
);
|
||||||
|
|
||||||
|
registerCleanupFunction(async () => {
|
||||||
|
// Remove the experiment from disk
|
||||||
|
const fileStore = new JSONFile({ path: getPath() });
|
||||||
|
await fileStore.load();
|
||||||
|
fileStore.data = {};
|
||||||
|
fileStore.saveSoon();
|
||||||
|
await fileStore.finalize();
|
||||||
|
apiStoreStub.restore();
|
||||||
|
});
|
||||||
|
});
|
|
@ -91,7 +91,7 @@ add_task(async function test_experiment_expose_Telemetry() {
|
||||||
featureId: "test-feature",
|
featureId: "test-feature",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { featureId } = experiment.branch.feature;
|
const { featureId } = experiment.branch.features[0];
|
||||||
const feature = new ExperimentFeature(featureId);
|
const feature = new ExperimentFeature(featureId);
|
||||||
|
|
||||||
Services.telemetry.clearEvents();
|
Services.telemetry.clearEvents();
|
||||||
|
|
|
@ -530,11 +530,13 @@ add_task(async function remote_defaults_active_experiments_check() {
|
||||||
branches: [
|
branches: [
|
||||||
{
|
{
|
||||||
slug: "mochitest-active-foo",
|
slug: "mochitest-active-foo",
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
featureId: "foo",
|
featureId: "foo",
|
||||||
value: null,
|
value: null,
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
active: true,
|
active: true,
|
||||||
|
|
|
@ -140,7 +140,7 @@ add_task(async function test_getExperiment_feature() {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "treatment",
|
slug: "treatment",
|
||||||
value: { title: "hi" },
|
value: { title: "hi" },
|
||||||
feature: { featureId: "cfr", enabled: true, value: null },
|
features: [{ featureId: "cfr", enabled: true, value: null }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -264,7 +264,7 @@ add_task(async function test_addExperiment_eventEmit_add() {
|
||||||
const experiment = ExperimentFakes.experiment("foo", {
|
const experiment = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: { featureId: "purple", enabled: true, value: null },
|
features: [{ featureId: "purple", enabled: true, value: null }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const store = ExperimentFakes.store();
|
const store = ExperimentFakes.store();
|
||||||
|
@ -299,7 +299,7 @@ add_task(async function test_updateExperiment_eventEmit_add_and_update() {
|
||||||
const experiment = ExperimentFakes.experiment("foo", {
|
const experiment = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: { featureId: "purple", enabled: true, value: null },
|
features: [{ featureId: "purple", enabled: true, value: null }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const store = ExperimentFakes.store();
|
const store = ExperimentFakes.store();
|
||||||
|
@ -333,7 +333,7 @@ add_task(async function test_updateExperiment_eventEmit_off() {
|
||||||
const experiment = ExperimentFakes.experiment("foo", {
|
const experiment = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: { featureId: "purple", enabled: true, value: null },
|
features: [{ featureId: "purple", enabled: true, value: null }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const store = ExperimentFakes.store();
|
const store = ExperimentFakes.store();
|
||||||
|
@ -363,7 +363,7 @@ add_task(async function test_activateBranch() {
|
||||||
const experiment = ExperimentFakes.experiment("foo", {
|
const experiment = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: { featureId: "green", enabled: true, value: null },
|
features: [{ featureId: "green", enabled: true, value: null }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -403,7 +403,7 @@ add_task(async function test_activateBranch_storeFailure() {
|
||||||
const experiment = ExperimentFakes.experiment("foo", {
|
const experiment = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: { featureId: "green", enabled: true },
|
features: [{ featureId: "green", enabled: true }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -430,7 +430,7 @@ add_task(async function test_activateBranch_noActivationEvent() {
|
||||||
const experiment = ExperimentFakes.experiment("foo", {
|
const experiment = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: { featureId: "green", enabled: true },
|
features: [{ featureId: "green", enabled: true }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -163,10 +163,12 @@ add_task(
|
||||||
const expected = ExperimentFakes.experiment("foo", {
|
const expected = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "treatment",
|
slug: "treatment",
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
featureId: "foo",
|
featureId: "foo",
|
||||||
value: { enabled: true },
|
value: { enabled: true },
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const featureInstance = new ExperimentFeature("foo", FAKE_FEATURE_MANIFEST);
|
const featureInstance = new ExperimentFeature("foo", FAKE_FEATURE_MANIFEST);
|
||||||
|
@ -229,10 +231,12 @@ add_task(async function test_record_exposure_event() {
|
||||||
ExperimentFakes.experiment("blah", {
|
ExperimentFakes.experiment("blah", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "treatment",
|
slug: "treatment",
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
featureId: "foo",
|
featureId: "foo",
|
||||||
value: { enabled: false },
|
value: { enabled: false },
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -259,10 +263,12 @@ add_task(async function test_record_exposure_event_once() {
|
||||||
ExperimentFakes.experiment("blah", {
|
ExperimentFakes.experiment("blah", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "treatment",
|
slug: "treatment",
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
featureId: "foo",
|
featureId: "foo",
|
||||||
value: { enabled: false },
|
value: { enabled: false },
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -365,11 +371,13 @@ add_task(async function test_isEnabled_backwards_compatible() {
|
||||||
ExperimentFakes.experiment("blah", {
|
ExperimentFakes.experiment("blah", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "treatment",
|
slug: "treatment",
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
featureId: "foo",
|
featureId: "foo",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
value: {},
|
value: {},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -386,7 +394,15 @@ add_task(async function test_onUpdate_before_store_ready() {
|
||||||
sandbox.stub(ExperimentAPI, "_store").get(() => manager.store);
|
sandbox.stub(ExperimentAPI, "_store").get(() => manager.store);
|
||||||
sandbox.stub(manager.store, "getAllActive").returns([
|
sandbox.stub(manager.store, "getAllActive").returns([
|
||||||
ExperimentFakes.experiment("foo-experiment", {
|
ExperimentFakes.experiment("foo-experiment", {
|
||||||
branch: { slug: "control", feature: { featureId: "foo", value: null } },
|
branch: {
|
||||||
|
slug: "control",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
featureId: "foo",
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
@ -76,11 +76,13 @@ add_task(
|
||||||
const recipe = ExperimentFakes.experiment("awexperiment", {
|
const recipe = ExperimentFakes.experiment("awexperiment", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "treatment",
|
slug: "treatment",
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
featureId: "aboutwelcome",
|
featureId: "aboutwelcome",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
value: { screens: ["test-value"] },
|
value: { screens: ["test-value"] },
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -218,7 +218,7 @@ add_task(async function test_getVariable_no_mutation() {
|
||||||
Cu.cloneInto(
|
Cu.cloneInto(
|
||||||
{
|
{
|
||||||
branch: {
|
branch: {
|
||||||
feature: { value: { mochitest: true } },
|
features: [{ featureId: "aboutwelcome", value: { mochitest: true } }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
|
|
|
@ -129,11 +129,11 @@ add_task(async function test_failure_group_conflict() {
|
||||||
// These should not be allowed to exist simultaneously.
|
// These should not be allowed to exist simultaneously.
|
||||||
const existingBranch = {
|
const existingBranch = {
|
||||||
slug: "treatment",
|
slug: "treatment",
|
||||||
feature: { featureId: "pink", enabled: true, value: {} },
|
features: [{ featureId: "pink", enabled: true, value: {} }],
|
||||||
};
|
};
|
||||||
const newBranch = {
|
const newBranch = {
|
||||||
slug: "treatment",
|
slug: "treatment",
|
||||||
feature: { featureId: "pink", enabled: true, value: {} },
|
features: [{ featureId: "pink", enabled: true, value: {} }],
|
||||||
};
|
};
|
||||||
|
|
||||||
// simulate adding an experiment with a conflicting group "pink"
|
// simulate adding an experiment with a conflicting group "pink"
|
||||||
|
@ -234,7 +234,7 @@ add_task(async function enroll_in_reference_aw_experiment() {
|
||||||
const branches = ["treatment-a", "treatment-b"].map(slug => ({
|
const branches = ["treatment-a", "treatment-b"].map(slug => ({
|
||||||
slug,
|
slug,
|
||||||
ratio: 1,
|
ratio: 1,
|
||||||
feature: { value: content, enabled: true, featureId: "aboutwelcome" },
|
features: [{ value: content, enabled: true, featureId: "aboutwelcome" }],
|
||||||
}));
|
}));
|
||||||
let recipe = ExperimentFakes.recipe("reference-aw", { branches });
|
let recipe = ExperimentFakes.recipe("reference-aw", { branches });
|
||||||
// Ensure we get enrolled
|
// Ensure we get enrolled
|
||||||
|
@ -276,7 +276,7 @@ add_task(async function test_forceEnroll_cleanup() {
|
||||||
{
|
{
|
||||||
slug: "treatment",
|
slug: "treatment",
|
||||||
ratio: 1,
|
ratio: 1,
|
||||||
feature: { featureId: "force-enrollment", enabled: true, value: {} },
|
features: [{ featureId: "force-enrollment", enabled: true, value: {} }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
@ -285,7 +285,7 @@ add_task(async function test_forceEnroll_cleanup() {
|
||||||
{
|
{
|
||||||
slug: "treatment",
|
slug: "treatment",
|
||||||
ratio: 1,
|
ratio: 1,
|
||||||
feature: { featureId: "force-enrollment", enabled: true, value: {} },
|
features: [{ featureId: "force-enrollment", enabled: true, value: {} }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
@ -325,10 +325,12 @@ add_task(async function test_featuremanifest_enum() {
|
||||||
{
|
{
|
||||||
slug: "control",
|
slug: "control",
|
||||||
ratio: 1,
|
ratio: 1,
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
featureId: "privatebrowsing",
|
featureId: "privatebrowsing",
|
||||||
value: { promoLinkType: "link" },
|
value: { promoLinkType: "link" },
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
@ -356,10 +358,12 @@ add_task(async function test_featuremanifest_enum() {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "control",
|
slug: "control",
|
||||||
ratio: 1,
|
ratio: 1,
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
featureId: "privatebrowsing",
|
featureId: "privatebrowsing",
|
||||||
value: { promoLinkType: "bar" },
|
value: { promoLinkType: "bar" },
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -166,7 +166,6 @@ add_task(async function test_onRecipe_isEnrollmentPaused() {
|
||||||
await manager.enroll(fooRecipe, "test");
|
await manager.enroll(fooRecipe, "test");
|
||||||
await enrollmentPromise;
|
await enrollmentPromise;
|
||||||
await manager.onRecipe(updatedRecipe, "test");
|
await manager.onRecipe(updatedRecipe, "test");
|
||||||
console.log("XXX", manager.updateEnrollment.callCount);
|
|
||||||
Assert.equal(
|
Assert.equal(
|
||||||
manager.updateEnrollment.calledWith(updatedRecipe),
|
manager.updateEnrollment.calledWith(updatedRecipe),
|
||||||
true,
|
true,
|
||||||
|
@ -199,12 +198,12 @@ add_task(async function test_onFinalize_unenroll() {
|
||||||
|
|
||||||
const recipe1 = ExperimentFakes.recipe("bar");
|
const recipe1 = ExperimentFakes.recipe("bar");
|
||||||
// Unique features to prevent overlap
|
// Unique features to prevent overlap
|
||||||
recipe1.branches[0].feature.featureId = "red";
|
recipe1.branches[0].features[0].featureId = "red";
|
||||||
recipe1.branches[1].feature.featureId = "red";
|
recipe1.branches[1].features[0].featureId = "red";
|
||||||
await manager.onRecipe(recipe1, "test");
|
await manager.onRecipe(recipe1, "test");
|
||||||
const recipe2 = ExperimentFakes.recipe("baz");
|
const recipe2 = ExperimentFakes.recipe("baz");
|
||||||
recipe2.branches[0].feature.featureId = "green";
|
recipe2.branches[0].features[0].featureId = "green";
|
||||||
recipe2.branches[1].feature.featureId = "green";
|
recipe2.branches[1].features[0].featureId = "green";
|
||||||
await manager.onRecipe(recipe2, "test");
|
await manager.onRecipe(recipe2, "test");
|
||||||
|
|
||||||
// Finalize
|
// Finalize
|
||||||
|
|
|
@ -23,7 +23,7 @@ add_task(async function test_usageBeforeInitialization() {
|
||||||
const experiment = ExperimentFakes.experiment("foo", {
|
const experiment = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: { featureId: "purple", enabled: true },
|
features: [{ featureId: "purple", enabled: true }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -69,7 +69,10 @@ add_task(async function test_event_updates_main() {
|
||||||
await store.init();
|
await store.init();
|
||||||
|
|
||||||
// Set update cb
|
// Set update cb
|
||||||
store.on(`update:${experiment.branch.feature.featureId}`, updateEventCbStub);
|
store.on(
|
||||||
|
`update:${experiment.branch.features[0].featureId}`,
|
||||||
|
updateEventCbStub
|
||||||
|
);
|
||||||
|
|
||||||
store.addExperiment(experiment);
|
store.addExperiment(experiment);
|
||||||
store.updateExperiment("foo", { active: false });
|
store.updateExperiment("foo", { active: false });
|
||||||
|
@ -85,7 +88,10 @@ add_task(async function test_event_updates_main() {
|
||||||
"Should be called with updated experiment status"
|
"Should be called with updated experiment status"
|
||||||
);
|
);
|
||||||
|
|
||||||
store.off(`update:${experiment.branch.feature.featureId}`, updateEventCbStub);
|
store.off(
|
||||||
|
`update:${experiment.branch.features[0].featureId}`,
|
||||||
|
updateEventCbStub
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
add_task(async function test_getExperimentForGroup() {
|
add_task(async function test_getExperimentForGroup() {
|
||||||
|
@ -93,7 +99,7 @@ add_task(async function test_getExperimentForGroup() {
|
||||||
const experiment = ExperimentFakes.experiment("foo", {
|
const experiment = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: { featureId: "purple", enabled: true },
|
features: [{ featureId: "purple", enabled: true }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -194,9 +200,9 @@ add_task(async function test_addExperiment() {
|
||||||
});
|
});
|
||||||
|
|
||||||
add_task(async function test_updateExperiment() {
|
add_task(async function test_updateExperiment() {
|
||||||
const feature = { featureId: "cfr", enabled: true };
|
const features = [{ featureId: "cfr", enabled: true }];
|
||||||
const experiment = Object.freeze(
|
const experiment = Object.freeze(
|
||||||
ExperimentFakes.experiment("foo", { feature, active: true })
|
ExperimentFakes.experiment("foo", { features, active: true })
|
||||||
);
|
);
|
||||||
const store = ExperimentFakes.store();
|
const store = ExperimentFakes.store();
|
||||||
|
|
||||||
|
@ -207,8 +213,8 @@ add_task(async function test_updateExperiment() {
|
||||||
const actual = store.get("foo");
|
const actual = store.get("foo");
|
||||||
Assert.equal(actual.active, false, "should change updated props");
|
Assert.equal(actual.active, false, "should change updated props");
|
||||||
Assert.deepEqual(
|
Assert.deepEqual(
|
||||||
actual.branch.feature,
|
actual.branch.features,
|
||||||
feature,
|
features,
|
||||||
"should not update other props"
|
"should not update other props"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -221,7 +227,7 @@ add_task(async function test_sync_access_before_init() {
|
||||||
Assert.equal(store.getAll().length, 0, "Start with an empty store");
|
Assert.equal(store.getAll().length, 0, "Start with an empty store");
|
||||||
|
|
||||||
const syncAccessExp = ExperimentFakes.experiment("foo", {
|
const syncAccessExp = ExperimentFakes.experiment("foo", {
|
||||||
feature: { featureId: "newtab", enabled: "true" },
|
features: [{ featureId: "newtab", enabled: "true" }],
|
||||||
});
|
});
|
||||||
await store.init();
|
await store.init();
|
||||||
store.addExperiment(syncAccessExp);
|
store.addExperiment(syncAccessExp);
|
||||||
|
@ -253,7 +259,7 @@ add_task(async function test_sync_access_update() {
|
||||||
|
|
||||||
let store = ExperimentFakes.store();
|
let store = ExperimentFakes.store();
|
||||||
let experiment = ExperimentFakes.experiment("foo", {
|
let experiment = ExperimentFakes.experiment("foo", {
|
||||||
feature: { featureId: "aboutwelcome", enabled: true },
|
features: [{ featureId: "aboutwelcome", enabled: true }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await store.init();
|
await store.init();
|
||||||
|
@ -262,11 +268,13 @@ add_task(async function test_sync_access_update() {
|
||||||
store.updateExperiment("foo", {
|
store.updateExperiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
...experiment.branch,
|
...experiment.branch,
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
featureId: "aboutwelcome",
|
featureId: "aboutwelcome",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
value: { bar: "bar" },
|
value: { bar: "bar" },
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -275,6 +283,8 @@ add_task(async function test_sync_access_update() {
|
||||||
|
|
||||||
Assert.ok(cachedExperiment, "Got back 1 experiment");
|
Assert.ok(cachedExperiment, "Got back 1 experiment");
|
||||||
Assert.deepEqual(
|
Assert.deepEqual(
|
||||||
|
// `branch.feature` and not `features` because for sync access (early startup)
|
||||||
|
// experiments we only store the `isEarlyStartup` feature
|
||||||
cachedExperiment.branch.feature.value,
|
cachedExperiment.branch.feature.value,
|
||||||
{ bar: "bar" },
|
{ bar: "bar" },
|
||||||
"Got updated value"
|
"Got updated value"
|
||||||
|
@ -286,7 +296,7 @@ add_task(async function test_sync_features_only() {
|
||||||
|
|
||||||
let store = ExperimentFakes.store();
|
let store = ExperimentFakes.store();
|
||||||
let experiment = ExperimentFakes.experiment("foo", {
|
let experiment = ExperimentFakes.experiment("foo", {
|
||||||
feature: { featureId: "cfr", enabled: true },
|
features: [{ featureId: "cfr", enabled: true }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await store.init();
|
await store.init();
|
||||||
|
@ -302,7 +312,7 @@ add_task(async function test_sync_features_remotely() {
|
||||||
|
|
||||||
let store = ExperimentFakes.store();
|
let store = ExperimentFakes.store();
|
||||||
let experiment = ExperimentFakes.experiment("foo", {
|
let experiment = ExperimentFakes.experiment("foo", {
|
||||||
feature: { featureId: "cfr", enabled: true, isEarlyStartup: true },
|
features: [{ featureId: "cfr", enabled: true, isEarlyStartup: true }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await store.init();
|
await store.init();
|
||||||
|
@ -322,7 +332,7 @@ add_task(async function test_sync_access_unenroll() {
|
||||||
|
|
||||||
let store = ExperimentFakes.store();
|
let store = ExperimentFakes.store();
|
||||||
let experiment = ExperimentFakes.experiment("foo", {
|
let experiment = ExperimentFakes.experiment("foo", {
|
||||||
feature: { featureId: "aboutwelcome", enabled: true },
|
features: [{ featureId: "aboutwelcome", enabled: true }],
|
||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -342,10 +352,10 @@ add_task(async function test_sync_access_unenroll_2() {
|
||||||
|
|
||||||
let store = ExperimentFakes.store();
|
let store = ExperimentFakes.store();
|
||||||
let experiment1 = ExperimentFakes.experiment("foo", {
|
let experiment1 = ExperimentFakes.experiment("foo", {
|
||||||
feature: { featureId: "newtab", enabled: true },
|
features: [{ featureId: "newtab", enabled: true }],
|
||||||
});
|
});
|
||||||
let experiment2 = ExperimentFakes.experiment("bar", {
|
let experiment2 = ExperimentFakes.experiment("bar", {
|
||||||
feature: { featureId: "aboutwelcome", enabled: true },
|
features: [{ featureId: "aboutwelcome", enabled: true }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await store.init();
|
await store.init();
|
||||||
|
@ -529,12 +539,14 @@ add_task(async function test_storeValuePerPref_noVariables() {
|
||||||
const experiment = ExperimentFakes.experiment("foo", {
|
const experiment = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
// Ensure it gets saved to prefs
|
// Ensure it gets saved to prefs
|
||||||
isEarlyStartup: true,
|
isEarlyStartup: true,
|
||||||
featureId: "purple",
|
featureId: "purple",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -562,12 +574,14 @@ add_task(async function test_storeValuePerPref_withVariables() {
|
||||||
const experiment = ExperimentFakes.experiment("foo", {
|
const experiment = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
// Ensure it gets saved to prefs
|
// Ensure it gets saved to prefs
|
||||||
isEarlyStartup: true,
|
isEarlyStartup: true,
|
||||||
featureId: "purple",
|
featureId: "purple",
|
||||||
value: { color: "purple", enabled: true },
|
value: { color: "purple", enabled: true },
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -576,12 +590,11 @@ add_task(async function test_storeValuePerPref_withVariables() {
|
||||||
|
|
||||||
let branch = Services.prefs.getBranch(`${SYNC_DATA_PREF_BRANCH}purple.`);
|
let branch = Services.prefs.getBranch(`${SYNC_DATA_PREF_BRANCH}purple.`);
|
||||||
|
|
||||||
|
let val = Services.prefs.getStringPref(`${SYNC_DATA_PREF_BRANCH}purple`);
|
||||||
Assert.equal(
|
Assert.equal(
|
||||||
Services.prefs
|
val.indexOf("color"),
|
||||||
.getStringPref(`${SYNC_DATA_PREF_BRANCH}purple`)
|
|
||||||
.indexOf("color"),
|
|
||||||
-1,
|
-1,
|
||||||
"Experiment metadata does not contain variables"
|
`Experiment metadata does not contain variables ${val}`
|
||||||
);
|
);
|
||||||
|
|
||||||
Assert.equal(branch.getChildList("").length, 2, "Enabled and color");
|
Assert.equal(branch.getChildList("").length, 2, "Enabled and color");
|
||||||
|
@ -599,12 +612,14 @@ add_task(async function test_storeValuePerPref_returnsSameValue() {
|
||||||
const experiment = ExperimentFakes.experiment("foo", {
|
const experiment = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
// Ensure it gets saved to prefs
|
// Ensure it gets saved to prefs
|
||||||
isEarlyStartup: true,
|
isEarlyStartup: true,
|
||||||
featureId: "purple",
|
featureId: "purple",
|
||||||
value: { color: "purple", enabled: true },
|
value: { color: "purple", enabled: true },
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -613,11 +628,11 @@ add_task(async function test_storeValuePerPref_returnsSameValue() {
|
||||||
let branch = Services.prefs.getBranch(`${SYNC_DATA_PREF_BRANCH}purple.`);
|
let branch = Services.prefs.getBranch(`${SYNC_DATA_PREF_BRANCH}purple.`);
|
||||||
|
|
||||||
store = ExperimentFakes.store();
|
store = ExperimentFakes.store();
|
||||||
Assert.deepEqual(
|
const cachedExperiment = store.getExperimentForFeature("purple");
|
||||||
store.getExperimentForFeature("purple"),
|
// Cached experiment format only stores early access feature
|
||||||
experiment,
|
cachedExperiment.branch.features = [cachedExperiment.branch.feature];
|
||||||
"Returns the same value"
|
delete cachedExperiment.branch.feature;
|
||||||
);
|
Assert.deepEqual(cachedExperiment, experiment, "Returns the same value");
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
store._updateSyncStore({ ...experiment, active: false });
|
store._updateSyncStore({ ...experiment, active: false });
|
||||||
|
@ -645,7 +660,8 @@ add_task(async function test_storeValuePerPref_returnsSameValue_allTypes() {
|
||||||
const experiment = ExperimentFakes.experiment("foo", {
|
const experiment = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
// Ensure it gets saved to prefs
|
// Ensure it gets saved to prefs
|
||||||
isEarlyStartup: true,
|
isEarlyStartup: true,
|
||||||
featureId: "purple",
|
featureId: "purple",
|
||||||
|
@ -659,6 +675,7 @@ add_task(async function test_storeValuePerPref_returnsSameValue_allTypes() {
|
||||||
json: { jsonValue: true },
|
json: { jsonValue: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -669,7 +686,7 @@ add_task(async function test_storeValuePerPref_returnsSameValue_allTypes() {
|
||||||
store = ExperimentFakes.store();
|
store = ExperimentFakes.store();
|
||||||
Assert.deepEqual(
|
Assert.deepEqual(
|
||||||
store.getExperimentForFeature("purple").branch.feature.value,
|
store.getExperimentForFeature("purple").branch.feature.value,
|
||||||
experiment.branch.feature.value,
|
experiment.branch.features[0].value,
|
||||||
"Returns the same value"
|
"Returns the same value"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -690,25 +707,25 @@ add_task(async function test_cleanupOldRecipes() {
|
||||||
const experiment1 = ExperimentFakes.experiment("foo", {
|
const experiment1 = ExperimentFakes.experiment("foo", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: { featureId: "purple", enabled: true },
|
features: [{ featureId: "purple", enabled: true }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const experiment2 = ExperimentFakes.experiment("bar", {
|
const experiment2 = ExperimentFakes.experiment("bar", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: { featureId: "purple", enabled: true },
|
features: [{ featureId: "purple", enabled: true }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const experiment3 = ExperimentFakes.experiment("baz", {
|
const experiment3 = ExperimentFakes.experiment("baz", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: { featureId: "purple", enabled: true },
|
features: [{ featureId: "purple", enabled: true }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const experiment4 = ExperimentFakes.experiment("faz", {
|
const experiment4 = ExperimentFakes.experiment("faz", {
|
||||||
branch: {
|
branch: {
|
||||||
slug: "variant",
|
slug: "variant",
|
||||||
feature: { featureId: "purple", enabled: true },
|
features: [{ featureId: "purple", enabled: true }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Exp 2 is kept because it's recent (even though it's not active)
|
// Exp 2 is kept because it's recent (even though it's not active)
|
||||||
|
|
|
@ -13,13 +13,23 @@ add_task(async function test_recipe_fake_validates() {
|
||||||
});
|
});
|
||||||
|
|
||||||
add_task(async function test_enrollmentHelper() {
|
add_task(async function test_enrollmentHelper() {
|
||||||
let recipe = ExperimentFakes.recipe("bar");
|
let recipe = ExperimentFakes.recipe("bar", {
|
||||||
recipe.branches.forEach(branch => {
|
branches: [
|
||||||
// Use a feature that will set the sync pref cache
|
{
|
||||||
branch.feature.featureId = "aboutwelcome";
|
slug: "control",
|
||||||
|
ratio: 1,
|
||||||
|
features: [{ featureId: "aboutwelcome", value: {} }],
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
let manager = ExperimentFakes.manager();
|
let manager = ExperimentFakes.manager();
|
||||||
|
|
||||||
|
Assert.deepEqual(
|
||||||
|
recipe.featureIds,
|
||||||
|
["aboutwelcome"],
|
||||||
|
"Helper sets correct featureIds"
|
||||||
|
);
|
||||||
|
|
||||||
await manager.onStartup();
|
await manager.onStartup();
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
|
@ -53,9 +53,12 @@ add_task(async function test_initialize() {
|
||||||
const experiment = sinon.stub(ExperimentAPI, "activateBranch").returns({
|
const experiment = sinon.stub(ExperimentAPI, "activateBranch").returns({
|
||||||
slug: "foo",
|
slug: "foo",
|
||||||
ratio: 1,
|
ratio: 1,
|
||||||
feature: {
|
features: [
|
||||||
|
{
|
||||||
|
featureId: "password-autocomplete",
|
||||||
value: { directMigrateSingleProfile: true },
|
value: { directMigrateSingleProfile: true },
|
||||||
},
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// This makes the last autocomplete test *not* show import suggestions.
|
// This makes the last autocomplete test *not* show import suggestions.
|
||||||
|
|
Загрузка…
Ссылка в новой задаче