1
0
Fork 0
firefox/toolkit/components/nimbus/test/unit/test_ExperimentAPI.js
Daniel Baumann 5e9a113729
Adding upstream version 140.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
2025-06-25 09:37:52 +02:00

827 lines
20 KiB
JavaScript

"use strict";
const { NimbusTelemetry } = ChromeUtils.importESModule(
"resource://nimbus/lib/Telemetry.sys.mjs"
);
const { TestUtils } = ChromeUtils.importESModule(
"resource://testing-common/TestUtils.sys.mjs"
);
const COLLECTION_ID_PREF = "messaging-system.rsexperimentloader.collection_id";
add_setup(function () {
Services.fog.initializeFOG();
});
/**
* #getRecipe
*/
add_task(async function test_getRecipe() {
const { sandbox, cleanup } = await NimbusTestUtils.setupTest();
const RECIPE = NimbusTestUtils.factories.recipe("foo");
const collectionName = Services.prefs.getStringPref(COLLECTION_ID_PREF);
sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").resolves([RECIPE]);
const recipe = await ExperimentAPI.getRecipe("foo");
Assert.deepEqual(
recipe,
RECIPE,
"should return an experiment recipe if found"
);
Assert.equal(
ExperimentAPI._remoteSettingsClient.collectionName,
collectionName,
"Loaded the expected collection"
);
await cleanup();
});
add_task(async function test_getRecipe_Failure() {
const { sandbox, cleanup } = await NimbusTestUtils.setupTest();
sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").throws();
const recipe = await ExperimentAPI.getRecipe("foo");
Assert.equal(recipe, undefined, "should return undefined if RS throws");
await cleanup();
});
/**
* #getAllBranches
*/
add_task(async function test_getAllBranches() {
const { sandbox, cleanup } = await NimbusTestUtils.setupTest();
const RECIPE = NimbusTestUtils.factories.recipe("foo");
sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").resolves([RECIPE]);
const branches = await ExperimentAPI.getAllBranches("foo");
Assert.deepEqual(
branches,
RECIPE.branches,
"should return all branches if found a recipe"
);
await cleanup();
});
// API used by Messaging System
add_task(async function test_getAllBranches_featureIdAccessor() {
const { sandbox, cleanup } = await NimbusTestUtils.setupTest();
const RECIPE = NimbusTestUtils.factories.recipe("foo");
sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").resolves([RECIPE]);
const branches = await ExperimentAPI.getAllBranches("foo");
Assert.deepEqual(
branches,
RECIPE.branches,
"should return all branches if found a recipe"
);
branches.forEach(branch => {
Assert.equal(
branch.testFeature.featureId,
"testFeature",
"Should use the experimentBranchAccessor proxy getter"
);
});
await cleanup();
});
// For schema version before 1.6.2 branch.feature was accessed
// instead of branch.features
add_task(async function test_getAllBranches_backwardsCompat() {
const { sandbox, cleanup } = await NimbusTestUtils.setupTest();
const RECIPE = NimbusTestUtils.factories.recipe("foo");
delete RECIPE.branches[0].features;
delete RECIPE.branches[1].features;
let feature = {
featureId: "backwardsCompat",
value: {
enabled: true,
},
};
RECIPE.branches[0].feature = feature;
RECIPE.branches[1].feature = feature;
sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").resolves([RECIPE]);
const branches = await ExperimentAPI.getAllBranches("foo");
Assert.deepEqual(
branches,
RECIPE.branches,
"should return all branches if found a recipe"
);
branches.forEach(branch => {
Assert.equal(
branch.backwardsCompat.featureId,
"backwardsCompat",
"Should use the experimentBranchAccessor proxy getter"
);
});
await cleanup();
});
add_task(async function test_getAllBranches_Failure() {
const { sandbox, cleanup } = await NimbusTestUtils.setupTest();
sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").throws();
const branches = await ExperimentAPI.getAllBranches("foo");
Assert.equal(branches, undefined, "should return undefined if RS throws");
await cleanup();
});
/**
* Store events
*/
add_task(async function test_addEnrollment_eventEmit_add() {
const { sandbox, manager, cleanup } = await NimbusTestUtils.setupTest();
const store = manager.store;
const featureStub = sandbox.stub();
const experiment = NimbusTestUtils.factories.experiment("foo", {
branch: {
slug: "variant",
ratio: 1,
features: [{ featureId: "purple", value: {} }],
},
});
await ExperimentAPI.ready();
store.on("featureUpdate:purple", featureStub);
store.addEnrollment(experiment);
Assert.equal(
featureStub.callCount,
1,
"should call 'featureUpdate' callback for featureId when an experiment is added"
);
Assert.equal(featureStub.firstCall.args[0], "featureUpdate:purple");
Assert.equal(featureStub.firstCall.args[1], "experiment-updated");
store.off("featureUpdate:purple", featureStub);
await manager.unenroll(experiment.slug);
await cleanup();
});
add_task(async function test_updateExperiment_eventEmit_add_and_update() {
const { sandbox, manager, cleanup } = await NimbusTestUtils.setupTest();
const store = manager.store;
const featureStub = sandbox.stub();
const experiment = NimbusTestUtils.factories.experiment("foo", {
branch: {
slug: "variant",
ratio: 1,
features: [{ featureId: "purple", value: {} }],
},
});
store.addEnrollment(experiment);
store._onFeatureUpdate("purple", featureStub);
store.updateExperiment(experiment.slug, experiment);
await TestUtils.waitForCondition(
() => featureStub.callCount == 2,
"Wait for `on` method to notify callback about the `add` event."
);
// Called twice, once when attaching the event listener (because there is an
// existing experiment with that name) and 2nd time for the update event
Assert.equal(featureStub.callCount, 2, "Called twice for feature");
Assert.equal(featureStub.firstCall.args[0], "featureUpdate:purple");
Assert.equal(featureStub.firstCall.args[1], "experiment-updated");
store._offFeatureUpdate("featureUpdate:purple", featureStub);
await manager.unenroll(experiment.slug);
await cleanup();
});
add_task(async function test_updateExperiment_eventEmit_off() {
const { manager, sandbox, cleanup } = await NimbusTestUtils.setupTest();
const store = manager.store;
const featureStub = sandbox.stub();
const experiment = NimbusTestUtils.factories.experiment("foo", {
branch: {
slug: "variant",
ratio: 1,
features: [{ featureId: "purple", value: {} }],
},
});
store.on("featureUpdate:purple", featureStub);
store.addEnrollment(experiment);
store.off("featureUpdate:purple", featureStub);
store.updateExperiment(experiment.slug, experiment);
Assert.equal(featureStub.callCount, 1, "Called only once before `off`");
await manager.unenroll(experiment.slug);
await cleanup();
});
add_task(async function testGetEnrollments() {
const { manager, cleanup } = await NimbusTestUtils.setupTest();
const fallbackPref = "nimbus.test-only.feature.baz";
const cleanupFeature = NimbusTestUtils.addTestFeatures(
new ExperimentFeature("foo", {
variables: {
foo: { type: "int" },
bar: { type: "boolean" },
baz: {
type: "string",
fallbackPref,
},
},
})
);
Services.prefs.setStringPref(fallbackPref, "pref-value");
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig("experiment", {
branchSlug: "treatment",
featureId: "foo",
value: { foo: 1, baz: "qux" },
}),
"test"
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout",
{ featureId: "foo", value: { foo: 2, bar: false } },
{ isRollout: true }
),
"test"
);
const enrollments = NimbusFeatures.foo
.getAllEnrollments()
.sort((a, b) => a.meta.slug.localeCompare(b.meta.slug));
Assert.deepEqual(
enrollments,
[
{
meta: {
slug: "experiment",
branch: "treatment",
isRollout: false,
},
value: {
foo: 1,
baz: "qux",
},
},
{
meta: {
slug: "rollout",
branch: "control",
isRollout: true,
},
value: {
foo: 2,
bar: false,
baz: "pref-value",
},
},
],
"Should have two enrollments"
);
await NimbusTestUtils.cleanupManager(["experiment", "rollout"], { manager });
Services.prefs.clearUserPref(fallbackPref);
cleanupFeature();
await cleanup();
});
add_task(async function testGetEnrollmentsCoenrolling() {
const { manager, cleanup } = await NimbusTestUtils.setupTest();
const fallbackPref = "nimbus.test-only.feature.baz";
const cleanupFeature = NimbusTestUtils.addTestFeatures(
new ExperimentFeature("foo", {
allowCoenrollment: true,
variables: {
foo: { type: "int" },
bar: { type: "boolean" },
baz: {
type: "string",
fallbackPref,
},
},
})
);
Services.prefs.setStringPref(fallbackPref, "pref-value");
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-1", {
branchSlug: "treatment-a",
featureId: "foo",
value: { foo: 1, baz: "qux" },
}),
"test"
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-2", {
branchSlug: "treatment-b",
featureId: "foo",
value: { foo: 2 },
}),
"test"
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout-1",
{ featureId: "foo", value: { foo: 3, bar: true } },
{ isRollout: true }
),
"test"
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout-2",
{ featureId: "foo", value: { bar: false, baz: "quux" } },
{ isRollout: true }
),
"test"
);
const enrollments = NimbusFeatures.foo
.getAllEnrollments()
.sort((a, b) => a.meta.slug.localeCompare(b.meta.slug));
Assert.deepEqual(
enrollments,
[
{
meta: {
slug: "experiment-1",
branch: "treatment-a",
isRollout: false,
},
value: {
foo: 1,
baz: "qux",
},
},
{
meta: {
slug: "experiment-2",
branch: "treatment-b",
isRollout: false,
},
value: {
foo: 2,
baz: "pref-value",
},
},
{
meta: {
slug: "rollout-1",
branch: "control",
isRollout: true,
},
value: {
foo: 3,
bar: true,
baz: "pref-value",
},
},
{
meta: {
slug: "rollout-2",
branch: "control",
isRollout: true,
},
value: {
bar: false,
baz: "quux",
},
},
],
"Should have four enrollments"
);
await NimbusTestUtils.cleanupManager(
["experiment-1", "experiment-2", "rollout-1", "rollout-2"],
{ manager }
);
Services.prefs.clearUserPref(fallbackPref);
cleanupFeature();
await cleanup();
});
add_task(async function testGetEnrollmentMetadata() {
const { manager, cleanup } = await NimbusTestUtils.setupTest();
const cleanupFeature = NimbusTestUtils.addTestFeatures(
new ExperimentFeature("foo", {
variables: {},
})
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig("experiment", {
branchSlug: "treatment",
featureId: "foo",
}),
"test"
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout",
{ featureId: "foo" },
{ isRollout: true }
),
"test"
);
const enrollments = NimbusFeatures.foo
.getAllEnrollmentMetadata()
.sort((a, b) => a.slug.localeCompare(b.slug));
Assert.deepEqual(
enrollments,
[
{
slug: "experiment",
branch: "treatment",
isRollout: false,
},
{
slug: "rollout",
branch: "control",
isRollout: true,
},
],
"Should have two enrollments"
);
await NimbusTestUtils.cleanupManager(["experiment", "rollout"], { manager });
cleanupFeature();
await cleanup();
});
add_task(async function testGetEnrollmentMetadataCoenrolling() {
const { manager, cleanup } = await NimbusTestUtils.setupTest();
const cleanupFeature = NimbusTestUtils.addTestFeatures(
new ExperimentFeature("foo", {
allowCoenrollment: true,
variables: {},
})
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-1", {
branchSlug: "treatment-a",
featureId: "foo",
}),
"test"
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-2", {
branchSlug: "treatment-b",
featureId: "foo",
}),
"test"
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout-1",
{ featureId: "foo" },
{ isRollout: true }
),
"test"
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout-2",
{ featureId: "foo" },
{ isRollout: true }
),
"test"
);
const enrollments = NimbusFeatures.foo
.getAllEnrollmentMetadata()
.sort((a, b) => a.slug.localeCompare(b.slug));
Assert.deepEqual(
enrollments,
[
{
slug: "experiment-1",
branch: "treatment-a",
isRollout: false,
},
{
slug: "experiment-2",
branch: "treatment-b",
isRollout: false,
},
{
slug: "rollout-1",
branch: "control",
isRollout: true,
},
{
slug: "rollout-2",
branch: "control",
isRollout: true,
},
],
"Should have four enrollments"
);
await NimbusTestUtils.cleanupManager(
["experiment-1", "experiment-2", "rollout-1", "rollout-2"],
{ manager }
);
cleanupFeature();
await cleanup();
});
add_task(async function testCoenrollingTraditionalApis() {
const { manager, cleanup } = await NimbusTestUtils.setupTest();
const cleanupFeature = NimbusTestUtils.addTestFeatures(
new ExperimentFeature("foo", {
allowCoenrollment: true,
variables: {},
})
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-1", {
branchSlug: "treatment-a",
featureId: "foo",
}),
"test"
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-2", {
branchSlug: "treatment-b",
featureId: "foo",
}),
"test"
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout-1",
{ featureId: "foo" },
{ isRollout: true }
),
"test"
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout-2",
{ featureId: "foo" },
{ isRollout: true }
),
"test"
);
Assert.throws(
() => NimbusFeatures.foo.getAllVariables(),
/Co-enrolling features must use the getAllEnrollments API/
);
Assert.throws(
() => NimbusFeatures.foo.getVariable("bar"),
/Co-enrolling features must use the getAllEnrollments API/
);
Assert.throws(
() => NimbusFeatures.foo.recordExposureEvent(),
/Co-enrolling features must provide slug/
);
Assert.throws(
() => NimbusFeatures.foo.getEnrollmentMetadata(),
/Co-enrolling features must use the getAllEnrollments or getAllEnrollmentMetadata APIs/
);
NimbusFeatures.foo.recordExposureEvent({ slug: "experiment-1" });
NimbusFeatures.foo.recordExposureEvent({ slug: "rollout-2" });
Assert.deepEqual(
Glean.nimbusEvents.exposure.testGetValue("events")?.map(ev => ev.extra),
[
{
experiment: "experiment-1",
branch: "treatment-a",
feature_id: "foo",
},
{
experiment: "rollout-2",
branch: "control",
feature_id: "foo",
},
]
);
await NimbusTestUtils.cleanupManager(
["experiment-1", "experiment-2", "rollout-1", "rollout-2"],
{ manager }
);
cleanupFeature();
await cleanup();
});
add_task(async function testGetEnrollmentMetadata() {
const feature = new ExperimentFeature("test-feature", {
variables: {},
});
const featureId = feature.featureId;
const cleanupFeature = NimbusTestUtils.addTestFeatures(feature);
const { manager, cleanup } = await NimbusTestUtils.setupTest();
const experimentMeta = {
slug: "experiment-slug",
branch: "treatment",
isRollout: false,
};
const rolloutMeta = {
slug: "rollout-slug",
branch: "control",
isRollout: true,
};
// There are no active enrollments.
Assert.equal(
NimbusFeatures[featureId].getEnrollmentMetadata("experiment"),
null
);
Assert.equal(
NimbusFeatures[featureId].getEnrollmentMetadata("rollout"),
null
);
Assert.equal(NimbusFeatures[featureId].getEnrollmentMetadata(), null);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout-slug",
{ featureId },
{ isRollout: true }
),
"test"
);
// Thre are no active experiments, but there is an active rollout.
Assert.equal(
NimbusFeatures[featureId].getEnrollmentMetadata("experiment"),
null
);
Assert.deepEqual(
NimbusFeatures[featureId].getEnrollmentMetadata("rollout"),
rolloutMeta
);
Assert.deepEqual(
NimbusFeatures[featureId].getEnrollmentMetadata(),
rolloutMeta
);
await manager.enroll(
NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-slug", {
featureId,
branchSlug: "treatment",
}),
"test"
);
// There is an active experiment and rollout, so we should get the experiment metadata by default.
Assert.deepEqual(
NimbusFeatures[featureId].getEnrollmentMetadata("experiment"),
experimentMeta
);
Assert.deepEqual(
NimbusFeatures[featureId].getEnrollmentMetadata("rollout"),
rolloutMeta
);
Assert.deepEqual(
NimbusFeatures[featureId].getEnrollmentMetadata(),
experimentMeta
);
await manager.unenroll("rollout-slug");
// There is only an active experiment.
Assert.deepEqual(
NimbusFeatures[featureId].getEnrollmentMetadata("experiment"),
experimentMeta
);
Assert.equal(
NimbusFeatures[featureId].getEnrollmentMetadata("rollout"),
null
);
Assert.deepEqual(
NimbusFeatures[featureId].getEnrollmentMetadata(),
experimentMeta
);
await manager.unenroll("experiment-slug");
// There are no active enrollments.
Assert.equal(
NimbusFeatures[featureId].getEnrollmentMetadata("experiment"),
null
);
Assert.equal(
NimbusFeatures[featureId].getEnrollmentMetadata("rollout"),
null
);
Assert.equal(NimbusFeatures[featureId].getEnrollmentMetadata(), null);
await cleanup();
cleanupFeature();
});
add_task(async function testGetEnrollmentMetadataSafe() {
const { sandbox, manager, cleanup } = await NimbusTestUtils.setupTest();
sandbox.stub(NimbusTelemetry, "recordExposure");
sandbox.stub(manager.store, "getExperimentForFeature").throws();
sandbox.stub(manager.store, "getRolloutForFeature").throws();
Assert.equal(
NimbusFeatures.testFeature.getEnrollmentMetadata(),
null,
"Should not throw"
);
Assert.equal(
NimbusFeatures.testFeature.getEnrollmentMetadata("experiment"),
null,
"Should not throw"
);
Assert.equal(
NimbusFeatures.testFeature.getEnrollmentMetadata("rollout"),
null,
"Should not throw"
);
Assert.equal(
manager.store.getExperimentForFeature.callCount,
2,
"getExperimentForFeature called"
);
Assert.equal(
manager.store.getRolloutForFeature.callCount,
1,
"getRolloutForFeature called"
);
NimbusFeatures.testFeature.recordExposureEvent();
Assert.ok(
NimbusTelemetry.recordExposure.notCalled,
"Should not record exposure"
);
Assert.equal(
manager.store.getExperimentForFeature.callCount,
3,
"getExperimentForFeature called"
);
await cleanup();
});
add_task(async function testGetProfileId() {
const { cleanup } = await NimbusTestUtils.setupTest();
Assert.ok(
Services.prefs.prefHasUserValue("nimbus.profileId"),
"nimbus.profileId set on user branch"
);
Assert.ok(
!!Services.prefs.getStringPref("nimbus.profileId"),
"can get profile ID pref"
);
Assert.equal(
ExperimentAPI.profileId,
Services.prefs.getStringPref("nimbus.profileId"),
"ExperimentAPI.profileId matches pref value"
);
await cleanup();
});