1
0
Fork 0
firefox/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.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

2225 lines
57 KiB
JavaScript

"use strict";
const { EnrollmentsContext, MatchStatus } = ChromeUtils.importESModule(
"resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs"
);
const { NimbusTelemetry } = ChromeUtils.importESModule(
"resource://nimbus/lib/Telemetry.sys.mjs"
);
const { PanelTestProvider } = ChromeUtils.importESModule(
"resource:///modules/asrouter/PanelTestProvider.sys.mjs"
);
const { TelemetryEnvironment } = ChromeUtils.importESModule(
"resource://gre/modules/TelemetryEnvironment.sys.mjs"
);
const { UnenrollmentCause } = ChromeUtils.importESModule(
"resource://nimbus/lib/ExperimentManager.sys.mjs"
);
function assertEnrollments(store, expectedActive, expectedInactive) {
for (const slug of expectedActive) {
Assert.ok(store.get(slug), `${slug} is present in the store`);
Assert.ok(store.get(slug).active, `${slug} is active`);
}
for (const slug of expectedInactive) {
Assert.ok(store.get(slug), `${slug} is present in the store`);
Assert.ok(!store.get(slug).active, `${slug} is not active`);
}
for (const enrollment of store.getAll()) {
const slug = enrollment.slug;
if (!expectedActive.includes(slug) && !expectedInactive.includes(slug)) {
Assert.ok(
false,
`Store has unexpected ${enrollment.active ? "active" : "inactive"} enrollment with slug ${slug}`
);
}
}
}
add_setup(async function setup() {
Services.fog.initializeFOG();
});
function setupTest({ ...args } = {}) {
return NimbusTestUtils.setupTest({ ...args, clearTelemetry: true });
}
add_task(async function test_updateRecipes_invalidFeatureId() {
const badRecipe = NimbusTestUtils.factories.recipe("foo", {
branches: [
{
slug: "control",
ratio: 1,
features: [
{
featureId: "invalid-feature-id",
value: { hello: "world" },
},
],
},
{
slug: "treatment",
ratio: 1,
features: [
{
featureId: "invalid-feature-id",
value: { hello: "goodbye" },
},
],
},
],
});
const { sandbox, loader, manager, cleanup } = await setupTest();
sandbox.spy(manager, "onRecipe");
sandbox.spy(manager, "enroll");
loader.remoteSettingsClients.experiments.get.resolves([badRecipe]);
await loader.updateRecipes();
Assert.ok(
manager.onRecipe.calledOnceWith(badRecipe, "rs-loader", {
ok: false,
reason: "invalid-feature",
featureIds: ["invalid-feature-id"],
}),
"should call onRecipe with invalid-feature"
);
Assert.ok(manager.enroll.notCalled, "Would not enroll");
Assert.deepEqual(
Glean.nimbusEvents.validationFailed
.testGetValue("events")
?.map(ev => ev.extra) ?? [],
[],
"Did not submit telemetry"
);
await cleanup();
});
add_task(async function test_updateRecipes_invalidFeatureValue() {
const badRecipe = NimbusTestUtils.factories.recipe("foo", {
branches: [
{
slug: "control",
ratio: 1,
features: [
{
featureId: "spotlight",
value: {
template: "spotlight",
},
},
],
},
{
slug: "treatment",
ratio: 1,
features: [
{
featureId: "spotlight",
value: {
template: "spotlight",
},
},
],
},
],
});
const { sandbox, loader, manager, cleanup } = await setupTest();
sandbox.spy(manager, "onRecipe");
sandbox.spy(manager, "enroll");
loader.remoteSettingsClients.experiments.get.resolves([badRecipe]);
await loader.updateRecipes();
Assert.ok(
manager.onRecipe.calledOnceWith(badRecipe, "rs-loader", {
ok: false,
reason: "invalid-branch",
branchSlugs: ["control", "treatment"],
}),
"Should call onRecipe with invalid-branch"
);
Assert.ok(manager.enroll.notCalled, "Would not enroll");
await cleanup();
});
add_task(async function test_updateRecipes_invalidRecipe() {
const badRecipe = NimbusTestUtils.factories.recipe("foo");
delete badRecipe.slug;
const { sandbox, loader, manager, cleanup } = await setupTest();
sandbox.spy(manager, "onRecipe");
sandbox.spy(manager, "enroll");
loader.remoteSettingsClients.experiments.get.resolves([badRecipe]);
await loader.updateRecipes();
Assert.ok(
manager.onRecipe.calledOnceWith(badRecipe, "rs-loader", {
ok: false,
reason: "invalid-recipe",
}),
"Should call onRecipe with invalid-recipe"
);
Assert.ok(manager.enroll.notCalled, "Would not enroll");
await cleanup();
});
add_task(async function test_updateRecipes_invalidRecipeAfterUpdate() {
const recipe = NimbusTestUtils.factories.recipe("foo");
const badRecipe = { ...recipe };
delete badRecipe.branches;
const { sandbox, loader, manager, cleanup } = await setupTest();
sandbox.spy(manager, "onRecipe");
sandbox.spy(manager, "updateEnrollment");
sandbox.spy(manager, "enroll");
sandbox.spy(manager, "_unenroll");
loader.remoteSettingsClients.experiments.get.resolves([recipe]);
await loader.updateRecipes();
Assert.ok(
manager.onRecipe.calledOnceWith(recipe, "rs-loader", {
ok: true,
status: MatchStatus.TARGETING_AND_BUCKETING,
}),
"Should call onRecipe with targeting and bucketing match"
);
Assert.ok(
manager.enroll.calledOnceWith(recipe, "rs-loader"),
"Should enroll"
);
info("Replacing recipe with an invalid one");
loader.remoteSettingsClients.experiments.get.resolves([badRecipe]);
await loader.updateRecipes();
Assert.ok(
manager.updateEnrollment.calledOnceWith(
sinon.match({ slug: recipe.slug }),
badRecipe,
"rs-loader",
{
ok: false,
reason: "invalid-recipe",
}
),
"Should call onRecipe with invalid-recipe"
);
Assert.ok(
manager._unenroll.calledOnceWith(sinon.match({ slug: recipe.slug }), {
reason: "invalid-recipe",
}),
"Should unenroll"
);
await cleanup();
});
add_task(async function test_updateRecipes_invalidBranchAfterUpdate() {
const message = await PanelTestProvider.getMessages().then(msgs =>
msgs.find(m => m.id === "MULTISTAGE_SPOTLIGHT_MESSAGE")
);
const recipe = NimbusTestUtils.factories.recipe("recipe", {
branches: [
{
slug: "control",
ratio: 1,
features: [
{
featureId: "spotlight",
value: { ...message },
},
],
},
{
slug: "treatment",
ratio: 1,
features: [
{
featureId: "spotlight",
value: { ...message },
},
],
},
],
});
const badRecipe = {
...recipe,
branches: [
{ ...recipe.branches[0] },
{
...recipe.branches[1],
features: [
{
...recipe.branches[1].features[0],
value: { ...message },
},
],
},
],
};
delete badRecipe.branches[1].features[0].value.template;
const { sandbox, loader, manager, cleanup } = await setupTest();
sandbox.spy(manager, "onRecipe");
sandbox.spy(manager, "updateEnrollment");
sandbox.spy(manager, "enroll");
sandbox.spy(manager, "_unenroll");
loader.remoteSettingsClients.experiments.get.resolves([recipe]);
await loader.updateRecipes();
Assert.ok(
manager.onRecipe.calledOnceWith(recipe, "rs-loader", {
ok: true,
status: MatchStatus.TARGETING_AND_BUCKETING,
}),
"should call onRecipe with targeting and bucketing match"
);
Assert.ok(
manager.enroll.calledOnceWith(recipe, "rs-loader"),
"should enroll"
);
info("Replacing recipe with an invalid one");
loader.remoteSettingsClients.experiments.get.resolves([badRecipe]);
await loader.updateRecipes();
Assert.ok(
manager.updateEnrollment.calledOnceWith(
sinon.match({ slug: recipe.slug }),
badRecipe,
"rs-loader",
{
ok: false,
reason: "invalid-branch",
branchSlugs: ["treatment"],
}
),
"Should call updateEnrollment with invalid-branch"
);
Assert.ok(
manager._unenroll.calledOnceWith(sinon.match({ slug: "recipe" }), {
reason: "invalid-branch",
}),
"should unenroll"
);
await cleanup();
});
add_task(async function test_updateRecipes_simpleFeatureInvalidAfterUpdate() {
const recipe = NimbusTestUtils.factories.recipe("recipe");
const badRecipe = {
...recipe,
branches: [
{
...recipe.branches[0],
features: [
{
featureId: "testFeature",
value: { testInt: "abc123", enabled: true },
},
],
},
{
...recipe.branches[1],
features: [
{
featureId: "testFeature",
value: { testInt: 456, enabled: true },
},
],
},
],
};
const EXPECTED_SCHEMA = {
$schema: "https://json-schema.org/draft/2019-09/schema",
title: "testFeature",
description: NimbusFeatures.testFeature.manifest.description,
type: "object",
properties: {
testInt: {
type: "integer",
},
enabled: {
type: "boolean",
},
testSetString: {
type: "string",
},
},
additionalProperties: true,
};
const { sandbox, loader, manager, initExperimentAPI, cleanup } =
await setupTest({ init: false, experiments: [recipe] });
sandbox.spy(loader, "updateRecipes");
sandbox.spy(EnrollmentsContext.prototype, "_generateVariablesOnlySchema");
sandbox.spy(manager, "onRecipe");
sandbox.spy(manager, "updateEnrollment");
sandbox.spy(manager, "enroll");
sandbox.spy(manager, "_unenroll");
await initExperimentAPI();
Assert.ok(
manager.onRecipe.calledOnceWith(recipe, "rs-loader", {
ok: true,
status: MatchStatus.TARGETING_AND_BUCKETING,
}),
"should call .onRecipe with targeting and bucketing match"
);
Assert.ok(
manager.enroll.calledOnceWith(recipe, "rs-loader"),
"Should enroll"
);
Assert.ok(
EnrollmentsContext.prototype._generateVariablesOnlySchema.calledOnce,
"Should have generated a schema for testFeature"
);
Assert.deepEqual(
EnrollmentsContext.prototype._generateVariablesOnlySchema.returnValues[0],
EXPECTED_SCHEMA,
"should have generated a schema with three fields"
);
info("Replacing recipe with an invalid one");
loader.remoteSettingsClients.experiments.get.resolves([badRecipe]);
await loader.updateRecipes();
Assert.ok(
manager.updateEnrollment.calledOnceWith(
sinon.match({ slug: recipe.slug }),
badRecipe,
"rs-loader",
{
ok: false,
reason: "invalid-branch",
branchSlugs: ["control"],
}
),
"Should call updateEnrollment with invalid-branch"
);
Assert.ok(
manager._unenroll.calledOnceWith(sinon.match({ slug: recipe.slug }), {
reason: "invalid-branch",
}),
"Should unenroll"
);
await cleanup();
});
add_task(async function test_updateRecipes_invalidFeatureAfterUpdate() {
const featureConfig = { featureId: "bogus", value: {} };
let storePath;
{
const store = NimbusTestUtils.stubs.store();
await store.init();
store.addEnrollment(
NimbusTestUtils.factories.experiment.withFeatureConfig(
"recipe",
featureConfig
)
);
storePath = await NimbusTestUtils.saveStore(store);
}
const { manager, cleanup } = await setupTest({
storePath,
experiments: [
NimbusTestUtils.factories.recipe.withFeatureConfig(
"recipe",
featureConfig
),
],
});
const enrollment = manager.store.get("recipe");
Assert.ok(!enrollment.active, "Should have unenrolled");
Assert.equal(
enrollment.unenrollReason,
"invalid-feature",
"Should have unenrolled"
);
Assert.deepEqual(
Glean.nimbusEvents.validationFailed
.testGetValue("events")
?.map(ev => ev.extra) ?? [],
[],
"Should not have submitted any validationFailed telemetry"
);
Assert.deepEqual(
Glean.nimbusEvents.unenrollment
.testGetValue("events")
?.map(ev => ev.extra) ?? [],
[
{
experiment: "recipe",
branch: enrollment.branch.slug,
reason: "invalid-feature",
},
]
);
await cleanup();
});
add_task(async function test_updateRecipes_validationTelemetry() {
const invalidRecipe = NimbusTestUtils.factories.recipe("invalid-recipe");
delete invalidRecipe.channel;
const invalidBranch = NimbusTestUtils.factories.recipe("invalid-branch");
invalidBranch.branches[0].features[0].value.testInt = "hello";
invalidBranch.branches[1].features[0].value.testInt = "world";
const invalidFeature = NimbusTestUtils.factories.recipe("invalid-feature", {
branches: [
{
slug: "control",
ratio: 1,
features: [
{
featureId: "unknown-feature",
value: { foo: "bar" },
},
{
featureId: "second-unknown-feature",
value: { baz: "qux" },
},
],
},
],
});
const TEST_CASES = [
{
recipe: invalidRecipe,
reason: "invalid-recipe",
events: [{}],
callCount: 1,
},
{
recipe: invalidBranch,
reason: "invalid-branch",
events: invalidBranch.branches.map(branch => ({ branch: branch.slug })),
callCount: 2,
},
{
recipe: invalidFeature,
reason: "invalid-feature",
events: [],
callCount: 0,
},
];
const LEGACY_FILTER = {
category: "normandy",
method: "validationFailed",
object: "nimbus_experiment",
};
for (const { recipe, reason, events, callCount } of TEST_CASES) {
info(`Testing validation failed telemetry for reason = "${reason}" ...`);
const { sandbox, initExperimentAPI, cleanup } = await setupTest({
init: false,
experiments: [recipe],
});
sandbox.spy(NimbusTelemetry, "recordValidationFailure");
await initExperimentAPI();
Assert.equal(
NimbusTelemetry.recordValidationFailure.callCount,
callCount,
`Should call recordValidationFailure ${callCount} times for reason ${reason}`
);
const gleanEvents =
Glean.nimbusEvents.validationFailed
.testGetValue("events")
?.map(event => event.extra) ?? [];
const expectedGleanEvents = events.map(event => ({
experiment: recipe.slug,
reason,
...event,
}));
Assert.deepEqual(
gleanEvents,
expectedGleanEvents,
"Glean telemetry matches"
);
const expectedLegacyEvents = events.map(event => ({
...LEGACY_FILTER,
value: recipe.slug,
extra: {
reason,
...event,
},
LEGACY_FILTER,
}));
TelemetryTestUtils.assertEvents(expectedLegacyEvents, LEGACY_FILTER);
await cleanup();
}
});
add_task(async function test_updateRecipes_validationDisabled() {
Services.prefs.setBoolPref("nimbus.validation.enabled", false);
const invalidRecipe = NimbusTestUtils.factories.recipe("invalid-recipe");
delete invalidRecipe.channel;
const invalidBranch = NimbusTestUtils.factories.recipe("invalid-branch");
invalidBranch.branches[0].features[0].value.testInt = "hello";
invalidBranch.branches[1].features[0].value.testInt = "world";
const invalidFeature = NimbusTestUtils.factories.recipe("invalid-feature", {
branches: [
{
slug: "control",
ratio: 1,
features: [
{
featureId: "unknown-feature",
value: { foo: "bar" },
},
{
featureId: "second-unknown-feature",
value: { baz: "qux" },
},
],
},
],
});
for (const recipe of [invalidRecipe, invalidBranch, invalidFeature]) {
const { sandbox, manager, initExperimentAPI, cleanup } = await setupTest({
init: false,
experiments: [recipe],
});
sandbox.stub(manager, "enroll");
sandbox.spy(manager, "onRecipe");
sandbox.spy(NimbusTelemetry, "recordValidationFailure");
await initExperimentAPI();
Assert.ok(
NimbusTelemetry.recordValidationFailure.notCalled,
"Should not send validation failed telemetry"
);
Assert.ok(
manager.onRecipe.calledOnceWith(recipe, "rs-loader", {
ok: true,
status: MatchStatus.TARGETING_AND_BUCKETING,
}),
"Should call onRecipe with no validation issues"
);
Assert.ok(
manager.enroll.calledOnceWith(recipe, "rs-loader"),
"Would enroll"
);
await cleanup();
}
Services.prefs.clearUserPref("nimbus.validation.enabled");
});
add_task(async function test_updateRecipes_appId() {
const recipe = NimbusTestUtils.factories.recipe.withFeatureConfig(
"background-task-recipe",
{ featureId: "backgroundTaskMessage" }
);
const { sandbox, loader, manager, cleanup } = await setupTest();
sandbox.spy(manager, "onRecipe");
sandbox.stub(manager, "enroll");
info("Testing updateRecipes() with the default application ID");
loader.remoteSettingsClients.experiments.get.resolves([recipe]);
await loader.updateRecipes();
Assert.ok(
manager.onRecipe.calledOnceWith(recipe, "rs-loader", {
ok: false,
reason: "unsupported-feature",
}),
"Should call onRecipe with unsupported-feature"
);
Assert.ok(manager.enroll.notCalled, "Would not enroll");
Assert.deepEqual(
Glean.nimbusEvents.validationFailed.testGetValue("events") ?? [],
[],
"There should be no validation failed event"
);
info("Testing updateRecipes() with a custom application ID");
manager.onRecipe.resetHistory();
Services.prefs.setStringPref(
"nimbus.appId",
"firefox-desktop-background-task"
);
await loader.updateRecipes();
Assert.ok(
manager.onRecipe.calledOnceWith(recipe, "rs-loader", {
ok: true,
status: MatchStatus.TARGETING_AND_BUCKETING,
}),
`onRecipe called`
);
Assert.ok(manager.enroll.calledOnceWith(recipe, "rs-loader"), "Would enroll");
Services.prefs.clearUserPref("nimbus.appId");
await cleanup();
});
add_task(async function test_updateRecipes_withPropNotInManifest() {
const recipe = NimbusTestUtils.factories.recipe("foo", {
branches: [
{
features: [
{
enabled: true,
featureId: "testFeature",
value: {
enabled: true,
testInt: 5,
testSetString: "foo",
additionalPropNotInManifest: 7,
},
},
],
ratio: 1,
slug: "treatment-2",
},
],
channel: "nightly",
schemaVersion: "1.9.0",
targeting: "true",
});
const { sandbox, loader, manager, cleanup } = await setupTest();
sandbox.stub(manager, "onRecipe");
loader.remoteSettingsClients.experiments.get.resolves([recipe]);
await loader.updateRecipes();
Assert.ok(
manager.onRecipe.calledOnceWith(recipe, "rs-loader", {
ok: true,
status: MatchStatus.TARGETING_AND_BUCKETING,
}),
"should call onRecipe with this recipe"
);
await cleanup();
});
add_task(async function test_updateRecipes_recipeAppId() {
const recipe = NimbusTestUtils.factories.recipe.withFeatureConfig(
"mobile-experiment",
{ featureId: "mobile-feature", value: { enabled: true } },
{ appId: "org.mozilla.firefox" }
);
const { sandbox, loader, manager, cleanup } = await setupTest();
sandbox.stub(manager, "onRecipe");
loader.remoteSettingsClients.experiments.get.resolves([recipe]);
await loader.updateRecipes();
Assert.ok(manager.onRecipe.notCalled, ".onRecipe was never called");
await cleanup();
});
add_task(async function test_updateRecipes_featureValidationOptOut() {
const invalidFeatureRecipe =
NimbusTestUtils.factories.recipe.withFeatureConfig("invalid-recipe", {
featureId: "testFeature",
value: { enabled: "true", testInt: false },
});
const message = await PanelTestProvider.getMessages().then(msgs =>
msgs.find(m => m.id === "MULTISTAGE_SPOTLIGHT_MESSAGE")
);
delete message.template;
const invalidMsgRecipe = NimbusTestUtils.factories.recipe.withFeatureConfig(
"invalid-recipe",
{ featureId: "spotlight", value: message }
);
for (const invalidRecipe of [invalidFeatureRecipe, invalidMsgRecipe]) {
const optOutRecipe = {
...invalidMsgRecipe,
slug: "optout-recipe",
featureValidationOptOut: true,
};
const { sandbox, loader, manager, cleanup } = await setupTest();
sandbox.stub(manager, "onRecipe");
loader.remoteSettingsClients.experiments.get.resolves([
invalidRecipe,
optOutRecipe,
]);
await loader.updateRecipes();
Assert.equal(manager.onRecipe.callCount, 2);
Assert.ok(
manager.onRecipe.calledWith(
invalidRecipe,
"rs-loader",
sinon.match({ ok: false })
),
"should call onRecipe for invalidRecipe with an error"
);
Assert.ok(
manager.onRecipe.calledWith(optOutRecipe, "rs-loader", {
ok: true,
status: MatchStatus.TARGETING_AND_BUCKETING,
}),
"should call onRecipe for optOutRecipe with targeting and bucketing match"
);
await cleanup();
}
});
add_task(async function test_updateRecipes_invalidFeature_mismatch() {
info(
"Testing that we do not submit validation telemetry when the targeting does not match"
);
const recipe = NimbusTestUtils.factories.recipe.withFeatureConfig(
"recipe",
{ featureId: "bogus", value: { bogus: "bogus" } },
{ targeting: "false" }
);
const { sandbox, loader, manager, cleanup } = await setupTest();
sandbox.stub(manager, "onRecipe");
sandbox.stub(NimbusTelemetry, "recordValidationFailure");
sandbox.spy(EnrollmentsContext.prototype, "checkTargeting");
sandbox.spy(EnrollmentsContext.prototype, "checkRecipe");
loader.remoteSettingsClients.experiments.get.resolves([recipe]);
await loader.updateRecipes();
Assert.ok(
EnrollmentsContext.prototype.checkTargeting.calledOnce,
"Should have checked targeting for recipe"
);
Assert.ok(
!(await EnrollmentsContext.prototype.checkTargeting.returnValues[0]),
"Targeting should not have matched"
);
Assert.deepEqual(
await EnrollmentsContext.prototype.checkRecipe.returnValues[0],
{ ok: true, status: MatchStatus.NO_MATCH },
"Recipe should be considered a targeting mismatch"
);
Assert.ok(
manager.onRecipe.calledOnceWith(recipe, "rs-loader", {
ok: true,
status: MatchStatus.NO_MATCH,
}),
"should call onRecipe for the recipe"
);
Assert.ok(
NimbusTelemetry.recordValidationFailure.notCalled,
"Should not have submitted validation failed telemetry"
);
await cleanup();
});
add_task(async function test_updateRecipes_rollout_bucketing() {
const experiment = NimbusTestUtils.factories.recipe.withFeatureConfig(
"experiment",
{ featureId: "testFeature" }
);
const rollout = NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout",
{ branchSlug: "rollout", featureId: "testFeature" },
{ isRollout: true }
);
const { loader, manager, cleanup } = await setupTest();
loader.remoteSettingsClients.experiments.get.resolves([experiment, rollout]);
await loader.updateRecipes();
Assert.equal(
manager.store.getExperimentForFeature("testFeature")?.slug,
experiment.slug,
"Should enroll in experiment"
);
Assert.equal(
manager.store.getRolloutForFeature("testFeature")?.slug,
rollout.slug,
"Should enroll in rollout"
);
experiment.bucketConfig.count = 0;
rollout.bucketConfig.count = 0;
await loader.updateRecipes();
Assert.equal(
manager.store.getExperimentForFeature("testFeature")?.slug,
experiment.slug,
"Should stay enrolled in experiment -- experiments cannot be resized"
);
Assert.ok(
!manager.store.getRolloutForFeature("testFeature"),
"Should unenroll from rollout"
);
const unenrollmentEvents =
Glean.nimbusEvents.unenrollment.testGetValue("events");
Assert.equal(
unenrollmentEvents.length,
1,
"Should be one unenrollment event"
);
Assert.equal(
unenrollmentEvents[0].extra.experiment,
rollout.slug,
"Experiment slug should match"
);
Assert.equal(
unenrollmentEvents[0].extra.reason,
"bucketing",
"Reason should match"
);
TelemetryTestUtils.assertEvents(
[
{
value: rollout.slug,
extra: {
reason: "bucketing",
},
},
],
{
category: "normandy",
method: "unenroll",
object: "nimbus_experiment",
}
);
await manager.unenroll(experiment.slug);
await cleanup();
});
add_task(async function test_reenroll_rollout_resized() {
const rollout = NimbusTestUtils.factories.recipe("rollout", {
isRollout: true,
});
const { loader, manager, cleanup } = await setupTest();
loader.remoteSettingsClients.experiments.get.resolves([rollout]);
await loader.updateRecipes();
Assert.equal(
manager.store.getRolloutForFeature("testFeature")?.slug,
rollout.slug,
"Should enroll in rollout"
);
rollout.bucketConfig.count = 0;
await loader.updateRecipes();
Assert.ok(
!manager.store.getRolloutForFeature("testFeature"),
"Should unenroll from rollout"
);
const enrollment = manager.store.get(rollout.slug);
Assert.equal(enrollment.unenrollReason, "bucketing");
rollout.bucketConfig.count = 1000;
await loader.updateRecipes();
Assert.equal(
manager.store.getRolloutForFeature("testFeature")?.slug,
rollout.slug,
"Should re-enroll in rollout"
);
const newEnrollment = manager.store.get(rollout.slug);
Assert.ok(
!Object.is(enrollment, newEnrollment),
"Should have new enrollment object"
);
Assert.ok(
!("unenrollReason" in newEnrollment),
"New enrollment should not have unenroll reason"
);
await manager.unenroll(rollout.slug);
await cleanup();
});
add_task(async function test_experiment_reenroll() {
const experiment = NimbusTestUtils.factories.recipe("experiment");
const { loader, manager, cleanup } = await setupTest();
await manager.enroll(experiment, "test");
Assert.equal(
manager.store.getExperimentForFeature("testFeature")?.slug,
experiment.slug,
"Should enroll in experiment"
);
await manager.unenroll(experiment.slug);
Assert.ok(
!manager.store.getExperimentForFeature("testFeature"),
"Should unenroll from experiment"
);
loader.remoteSettingsClients.experiments.get.resolves([experiment]);
await loader.updateRecipes();
Assert.ok(
!manager.store.getExperimentForFeature("testFeature"),
"Should not re-enroll in experiment"
);
await cleanup();
});
add_task(async function test_rollout_reenroll_optout() {
const rollout = NimbusTestUtils.factories.recipe("rollout", {
isRollout: true,
});
const { loader, manager, cleanup } = await setupTest({
experiments: [rollout],
});
Assert.ok(
manager.store.getRolloutForFeature("testFeature"),
"Should enroll in rollout"
);
manager.unenroll(
rollout.slug,
UnenrollmentCause.fromReason(
NimbusTelemetry.UnenrollReason.INDIVIDUAL_OPT_OUT
)
);
await loader.updateRecipes();
Assert.ok(
!manager.store.getRolloutForFeature("testFeature"),
"Should not re-enroll in rollout"
);
await cleanup();
});
add_task(async function test_active_and_past_experiment_targeting() {
const cleanupFeatures = NimbusTestUtils.addTestFeatures(
new ExperimentFeature("feature-a", {
isEarlyStartup: false,
variables: {},
}),
new ExperimentFeature("feature-b", {
isEarlyStartup: false,
variables: {},
}),
new ExperimentFeature("feature-c", { isEarlyStartup: false, variables: {} })
);
const experimentA = NimbusTestUtils.factories.recipe.withFeatureConfig(
"experiment-a",
{ featureId: "feature-a" }
);
const experimentB = NimbusTestUtils.factories.recipe.withFeatureConfig(
"experiment-b",
{ featureId: "feature-b" },
{ targeting: "'experiment-a' in activeExperiments" }
);
const experimentC = NimbusTestUtils.factories.recipe.withFeatureConfig(
"experiment-c",
{ featureId: "feature-c" },
{ targeting: "'experiment-a' in previousExperiments" }
);
const rolloutA = NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout-a",
{ featureId: "feature-a" },
{ isRollout: true }
);
const rolloutB = NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout-b",
{ featureId: "feature-b" },
{ targeting: "'rollout-a' in activeRollouts", isRollout: true }
);
const rolloutC = NimbusTestUtils.factories.recipe.withFeatureConfig(
"rollout-c",
{ featureId: "feature-c" },
{ targeting: "'rollout-a' in previousRollouts", isRollout: true }
);
const { loader, manager, cleanup } = await setupTest();
// Enroll in A.
loader.remoteSettingsClients.experiments.get.resolves([
experimentA,
rolloutA,
]);
await loader.updateRecipes();
assertEnrollments(manager.store, ["experiment-a", "rollout-a"], []);
loader.remoteSettingsClients.experiments.get.resolves([
experimentA,
experimentB,
experimentC,
rolloutA,
rolloutB,
rolloutC,
]);
await loader.updateRecipes();
// B will enroll becuase A is enrolled. C will not enroll because A is still
// enrolled.
assertEnrollments(
manager.store,
["experiment-a", "experiment-b", "rollout-a", "rollout-b"],
[]
);
loader.remoteSettingsClients.experiments.get.resolves([
experimentB,
experimentC,
rolloutB,
rolloutC,
]);
await loader.updateRecipes();
// Remove experiment A and rollout A to cause them to unenroll. B should
// unenroll as a result and C should enroll.
assertEnrollments(
manager.store,
["experiment-c", "rollout-c"],
["experiment-a", "experiment-b", "rollout-a", "rollout-b"]
);
await manager.unenroll("experiment-c");
await manager.unenroll("rollout-c");
cleanupFeatures();
await cleanup();
});
add_task(async function test_enrollment_targeting() {
const cleanupFeatures = NimbusTestUtils.addTestFeatures(
new ExperimentFeature("feature-a", {
isEarlyStartup: false,
variables: {},
}),
new ExperimentFeature("feature-b", {
isEarlyStartup: false,
variables: {},
}),
new ExperimentFeature("feature-c", {
isEarlyStartup: false,
variables: {},
}),
new ExperimentFeature("feature-d", {
isEarlyStartup: false,
variables: {},
})
);
function recipe(
name,
featureId,
{ targeting = "true", isRollout = false } = {}
) {
return NimbusTestUtils.factories.recipe.withFeatureConfig(
name,
{ featureId },
{ targeting, isRollout }
);
}
const experimentA = recipe("experiment-a", "feature-a", {
targeting: "!('rollout-c' in enrollments)",
});
const experimentB = recipe("experiment-b", "feature-b", {
targeting: "'rollout-a' in enrollments",
});
const experimentC = recipe("experiment-c", "feature-c");
const rolloutA = recipe("rollout-a", "feature-a", {
targeting: "!('experiment-c' in enrollments)",
isRollout: true,
});
const rolloutB = recipe("rollout-b", "feature-b", {
targeting: "'experiment-a' in enrollments",
isRollout: true,
});
const rolloutC = recipe("rollout-c", "feature-c", { isRollout: true });
const { loader, manager, cleanup } = await setupTest();
async function check(current, past, unenrolled) {
await loader.updateRecipes();
for (const slug of current) {
const enrollment = manager.store.get(slug);
Assert.equal(
enrollment?.active,
true,
`Enrollment exists for ${slug} and is active`
);
}
for (const slug of past) {
const enrollment = manager.store.get(slug);
Assert.equal(
enrollment?.active,
false,
`Enrollment exists for ${slug} and is inactive`
);
}
for (const slug of unenrolled) {
Assert.ok(
!manager.store.get(slug),
`Enrollment does not exist for ${slug}`
);
}
}
loader.remoteSettingsClients.experiments.get.resolves([
experimentB,
rolloutB,
]);
await check(
[],
[],
[
"experiment-a",
"experiment-b",
"experiment-c",
"rollout-a",
"rollout-b",
"rollout-c",
]
);
// Order matters -- B will be checked before A.
loader.remoteSettingsClients.experiments.get.resolves([
experimentB,
rolloutB,
experimentA,
rolloutA,
]);
await check(
["experiment-a", "rollout-a"],
[],
["experiment-b", "experiment-c", "rollout-b", "rollout-c"]
);
// B will see A enrolled.
loader.remoteSettingsClients.experiments.get.resolves([
experimentB,
rolloutB,
experimentA,
rolloutA,
]);
await check(
["experiment-a", "experiment-b", "rollout-a", "rollout-b"],
[],
["experiment-c", "rollout-c"]
);
// Order matters -- A will be checked before C.
loader.remoteSettingsClients.experiments.get.resolves([
experimentB,
rolloutB,
experimentA,
rolloutA,
experimentC,
rolloutC,
]);
await check(
[
"experiment-a",
"experiment-b",
"experiment-c",
"rollout-a",
"rollout-b",
"rollout-c",
],
[],
[]
);
// A will see C has enrolled and unenroll. B will stay enrolled.
await check(
["experiment-b", "experiment-c", "rollout-b", "rollout-c"],
["experiment-a", "rollout-a"],
[]
);
// A being unenrolled does not affect B. Rollout A will not re-enroll due to targeting.
await check(
["experiment-b", "experiment-c", "rollout-b", "rollout-c"],
["experiment-a", "rollout-a"],
[]
);
await NimbusTestUtils.cleanupManager([
"experiment-b",
"experiment-c",
"rollout-b",
"rollout-c",
]);
cleanupFeatures();
await cleanup();
});
add_task(async function test_update_experiments_ordered_by_published_date() {
// These are intentionally out of order so that we can test the order below.
const recipes = [
NimbusTestUtils.factories.recipe("published-date-2", {
publishedDate: "2024-01-05T12:00:00Z",
}),
NimbusTestUtils.factories.recipe("no-published-date-1"),
NimbusTestUtils.factories.recipe("published-date-1", {
publishedDate: "2024-01-03T12:00:00Z",
}),
NimbusTestUtils.factories.recipe("no-published-date-2"),
];
const { sandbox, loader, manager, cleanup } = await setupTest();
sandbox.stub(manager, "onRecipe");
loader.remoteSettingsClients.experiments.get.resolves(recipes);
await loader.updateRecipes();
Assert.ok(
manager.onRecipe
.getCall(0)
.calledWith(sinon.match({ slug: "no-published-date-1" }), "rs-loader")
);
Assert.ok(
manager.onRecipe
.getCall(1)
.calledWith(sinon.match({ slug: "no-published-date-2" }), "rs-loader")
);
Assert.ok(
manager.onRecipe
.getCall(2)
.calledWith(sinon.match({ slug: "published-date-1" }), "rs-loader")
);
Assert.ok(
manager.onRecipe
.getCall(3)
.calledWith(sinon.match({ slug: "published-date-2" }), "rs-loader")
);
await cleanup();
});
add_task(
async function test_record_is_ready_no_value_for_nimbus_is_ready_feature() {
const { loader, cleanup } = await NimbusTestUtils.setupTest({
clearTelemetry: true,
});
await Services.fog.testFlushAllChildren();
Services.fog.testResetFOG();
await loader.updateRecipes();
const isReadyEvents = Glean.nimbusEvents.isReady.testGetValue("events");
Assert.equal(isReadyEvents.length, 1);
await cleanup();
}
);
add_task(
async function test_record_is_ready_set_value_for_nimbus_is_ready_feature() {
const recipe = NimbusTestUtils.factories.recipe.withFeatureConfig("foo", {
branchSlug: "wsup",
featureId: "nimbusIsReady",
value: { eventCount: 3 },
});
const { loader, manager, cleanup } = await NimbusTestUtils.setupTest();
await Services.fog.testFlushAllChildren();
Services.fog.testResetFOG();
loader.remoteSettingsClients.experiments.get.resolves([recipe]);
await loader.updateRecipes();
const enrollment = manager.store.get(recipe.slug);
Assert.equal(enrollment?.active, true, `Enrollment exists and is active`);
const isReadyEvents = Glean.nimbusEvents.isReady.testGetValue("events");
Assert.equal(isReadyEvents.length, 3);
await manager.unenroll(recipe.slug);
await cleanup();
}
);
add_task(async function test_updateRecipes_secure() {
// This recipe is allowed from the secure collection but not the regular collection.
const prefFlipRecipe = NimbusTestUtils.factories.recipe.withFeatureConfig(
"pref-flip",
{
featureId: "prefFlips",
value: { prefs: {} },
}
);
const testFeatureRecipe = NimbusTestUtils.factories.recipe("test-feature");
const multiFeatureRecipe = NimbusTestUtils.factories.recipe("multi-feature", {
branches: [
{
...NimbusTestUtils.factories.recipe.branches[0],
features: [
prefFlipRecipe.branches[0].features[0],
testFeatureRecipe.branches[0].features[0],
],
},
],
});
const TEST_CASES = [
{
experiments: [prefFlipRecipe],
secureExperiments: [testFeatureRecipe],
shouldEnroll: [],
},
{
experiments: [testFeatureRecipe],
secureExperiments: [prefFlipRecipe],
shouldEnroll: [testFeatureRecipe, prefFlipRecipe],
},
{
experiments: [multiFeatureRecipe],
secureExperiments: [],
shouldEnroll: [],
},
{
experiments: [],
secureExperiments: [multiFeatureRecipe],
shouldEnroll: [],
},
];
for (const [
idx,
{ experiments, secureExperiments, shouldEnroll },
] of TEST_CASES.entries()) {
info(`Running test ${idx}`);
const { sandbox, manager, initExperimentAPI, cleanup } = await setupTest({
init: false,
experiments,
secureExperiments,
});
sandbox.stub(manager, "onRecipe");
await initExperimentAPI();
const enrolledSlugs = manager.onRecipe
.getCalls()
.map(call => call.args[0].slug);
Assert.equal(
manager.onRecipe.callCount,
shouldEnroll.length,
`Should enroll in expected number of recipes (enrolled in ${enrolledSlugs})`
);
for (const expectedRecipe of shouldEnroll) {
Assert.ok(
manager.onRecipe.calledWith(expectedRecipe, "rs-loader", {
ok: true,
status: MatchStatus.TARGETING_AND_BUCKETING,
}),
`Should enroll in ${expectedRecipe.slug}`
);
}
await cleanup();
}
});
add_task(async function test_updateRecipesClearsOptIns() {
const now = new Date().getTime();
const recipes = [
NimbusTestUtils.factories.recipe("opt-in-1", {
isFirefoxLabsOptIn: true,
firefoxLabsTitle: "opt-in-1-title",
firefoxLabsDescription: "opt-in-1-desc",
firefoxLabsDescriptionLinks: null,
firefoxLabsGroup: "group",
requiresRestart: false,
isRollout: true,
targeting: "true",
publishedDate: new Date(now).toISOString(),
}),
NimbusTestUtils.factories.recipe("opt-in-2", {
isFirefoxLabsOptIn: true,
firefoxLabsTitle: "opt-in-2-title",
firefoxLabsDescription: "opt-in-2-desc",
firefoxLabsDescriptionLinks: null,
firefoxLabsGroup: "group",
requiresRestart: false,
isRollout: true,
targeting: "false",
publishedDate: new Date(now + 10000).toISOString(),
}),
];
const { loader, manager, cleanup } = await setupTest();
loader.remoteSettingsClients.experiments.get.resolves(recipes);
await loader.updateRecipes();
Assert.deepEqual(manager.optInRecipes, recipes);
await loader.updateRecipes();
Assert.deepEqual(manager.optInRecipes, recipes);
await cleanup();
});
add_task(async function test_updateRecipes_optInsStayEnrolled() {
info("testing opt-ins stay enrolled after update");
const recipe = NimbusTestUtils.factories.recipe("opt-in", {
branches: [
{
...NimbusTestUtils.factories.recipe.branches[0],
slug: "branch-0",
firefoxLabsTitle: "branch-0-title",
},
{
...NimbusTestUtils.factories.recipe.branches[1],
slug: "branch-1",
firefoxLabsTitle: "branch-1-title",
},
],
targeting: "true",
isFirefoxLabsOptIn: true,
firefoxLabsTitle: "opt-in-title",
firefoxLabsDescription: "opt-in-desc",
firefoxLabsDescriptionLinks: null,
firefoxLabsGroup: "group",
requiresRestart: false,
});
const { loader, manager, cleanup } = await setupTest({
experiments: [recipe],
});
await manager.enroll(recipe, "rs-loader", { branchSlug: "branch-0" });
Assert.ok(manager.store.get("opt-in")?.active, "Opt-in was enrolled");
await loader.updateRecipes();
Assert.ok(manager.store.get("opt-in")?.active, "Opt-in stayed enrolled");
await manager.unenroll("opt-in");
await cleanup();
});
add_task(async function test_updateRecipes_optInsUnerollOnFalseTargeting() {
info("testing opt-ins unenroll after targeting becomes false");
const recipe = NimbusTestUtils.factories.recipe("opt-in", {
branches: [
{
...NimbusTestUtils.factories.recipe.branches[0],
slug: "branch-0",
firefoxLabsTitle: "branch-0-title",
},
{
...NimbusTestUtils.factories.recipe.branches[1],
slug: "branch-1",
firefoxLabsTitle: "branch-1-title",
},
],
targeting: "true",
isFirefoxLabsOptIn: true,
firefoxLabsTitle: "opt-in-title",
firefoxLabsDescription: "opt-in-desc",
firefoxLabsDescriptionLinks: null,
firefoxLabsGroup: "group",
requiresRestart: false,
});
const { loader, manager, cleanup } = await setupTest({
experiments: [recipe],
});
await manager.enroll(recipe, "rs-loader", { branchSlug: "branch-0" });
Assert.ok(manager.store.get("opt-in")?.active, "Opt-in was enrolled");
recipe.targeting = "false";
await loader.updateRecipes();
Assert.ok(!manager.store.get("opt-in")?.active, "Opt-in unenrolled");
await cleanup();
});
add_task(async function test_updateRecipes_bucketingCausesOptInUnenrollments() {
info("testing opt-in rollouts unenroll after if bucketing changes");
const recipe = NimbusTestUtils.factories.recipe("opt-in", {
branches: [
{
...NimbusTestUtils.factories.recipe.branches[0],
slug: "branch-0",
},
],
targeting: "true",
isFirefoxLabsOptIn: true,
isRollout: true,
firefoxLabsTitle: "opt-in-title",
firefoxLabsDescription: "opt-in-desc",
firefoxLabsDescriptionLinks: null,
firefoxLabsGroup: "group",
requiresRestart: false,
});
const { loader, manager, cleanup } = await setupTest({
experiments: [recipe],
});
await manager.enroll(recipe, "rs-loader", { branchSlug: "branch-0" });
Assert.ok(manager.store.get("opt-in")?.active, "Opt-in was enrolled");
recipe.bucketConfig.count = 0;
await loader.updateRecipes();
Assert.ok(!manager.store.get("opt-in")?.active, "Opt-in unenrolled");
await cleanup();
});
add_task(async function test_updateRecipes_reEnrollRolloutOptin() {
info(
"testing opt-in rollouts do not re-enroll automatically if bucketing changes"
);
const recipe = NimbusTestUtils.factories.recipe("opt-in", {
branches: [
{
...NimbusTestUtils.factories.recipe.branches[0],
slug: "branch-0",
},
],
targeting: "true",
isFirefoxLabsOptIn: true,
isRollout: true,
firefoxLabsTitle: "opt-in-title",
firefoxLabsDescription: "opt-in-desc",
firefoxLabsDescriptionLinks: null,
firefoxLabsGroup: "group",
requiresRestart: false,
});
const { loader, manager, cleanup } = await setupTest({
experiments: [recipe],
});
await manager.enroll(recipe, "rs-loader", { branchSlug: "branch-0" });
Assert.ok(manager.store.get("opt-in")?.active, "Opt-in was enrolled");
recipe.bucketConfig.count = 0;
await loader.updateRecipes();
Assert.ok(!manager.store.get("opt-in").active, "Opt-in unenrolled");
recipe.bucketConfig.count = 1000;
await loader.updateRecipes();
Assert.ok(!manager.store.get("opt-in").active, "Opt-in not re-enrolled");
await cleanup();
});
add_task(async function test_updateRecipes_enrollmentStatus_telemetry() {
// Create a feature for each experiment so that they aren't competing.
const features = [
new ExperimentFeature("test-feature-0", { variables: {} }),
new ExperimentFeature("test-feature-1", { variables: {} }),
new ExperimentFeature("test-feature-2", { variables: {} }),
new ExperimentFeature("test-feature-3", { variables: {} }),
new ExperimentFeature("test-feature-4", { variables: {} }),
new ExperimentFeature("test-feature-5", {
variables: {
foo: { type: "string" },
},
}),
new ExperimentFeature("test-feature-6", { variables: {} }),
new ExperimentFeature("test-feature-7", { variables: {} }),
new ExperimentFeature("test-feature-8", { variables: {} }),
];
const cleanupFeatures = NimbusTestUtils.addTestFeatures(...features);
function recipe(slug, featureId, value = null) {
return NimbusTestUtils.factories.recipe.withFeatureConfig(slug, {
featureId,
value: value ?? {},
});
}
const { loader, manager, cleanup } = await setupTest();
// Prime the store with currently valid recipes.
await manager.enroll(recipe("was-enrolled", "test-feature-0"), "rs-loader");
await manager.enroll(recipe("stays-enrolled", "test-feature-2"), "rs-loader");
await manager.enroll(
recipe("recipe-mismatch", "test-feature-3"),
"rs-loader"
);
await manager.enroll(recipe("invalid-recipe", "test-feature-4"), "rs-loader");
await manager.enroll(recipe("invalid-branch", "test-feature-5"), "rs-loader");
await manager.enroll(
recipe("invalid-feature", "test-feature-6"),
"rs-loader"
);
await manager.enroll(
recipe("l10n-missing-locale", "test-feature-7"),
"rs-loader"
);
await manager.enroll(
recipe("l10n-missing-entry", "test-feature-8"),
"rs-loader"
);
// Create another set of recipes, most of which are invalid, and trigger the
// RSEL with those recipes.
const recipes = [
recipe("enrolls", "test-feature-1"),
recipe("stays-enrolled", "test-feature-2"),
{
...recipe("recipe-mismatch", "test-feature-3"),
targeting: "false",
},
{
...recipe("invalid-recipe", "test-feature-4"),
isRollout: "true",
},
recipe("invalid-branch", "test-feature-5", {
foo: 1,
}),
recipe("invalid-feature", "unknown-feature"),
{
...recipe("l10n-missing-locale", "test-feature-6"),
localizations: {},
},
{
...recipe("l10n-missing-entry", "test-feature-7", {
foo: {
$l10n: {
id: "foo-string",
comment: "foo comment",
text: "foo text",
},
},
}),
localizations: {
"en-US": {},
},
},
];
loader.remoteSettingsClients.experiments.get.resolves(recipes);
await loader.updateRecipes("test");
const events = Glean.nimbusEvents.enrollmentStatus.testGetValue("events");
Assert.deepEqual(events?.map(ev => ev.extra) ?? [], [
{
reason: "Qualified",
branch: "control",
slug: "was-enrolled",
status: "Enrolled",
},
{
branch: "control",
reason: "Qualified",
status: "Enrolled",
slug: "stays-enrolled",
},
{
branch: "control",
slug: "recipe-mismatch",
status: "Enrolled",
reason: "Qualified",
},
{
branch: "control",
slug: "invalid-recipe",
reason: "Qualified",
status: "Enrolled",
},
{
slug: "invalid-branch",
reason: "Qualified",
status: "Enrolled",
branch: "control",
},
{
status: "Enrolled",
reason: "Qualified",
slug: "invalid-feature",
branch: "control",
},
{
slug: "l10n-missing-locale",
status: "Enrolled",
branch: "control",
reason: "Qualified",
},
{
status: "Enrolled",
slug: "l10n-missing-entry",
reason: "Qualified",
branch: "control",
},
{
branch: "control",
slug: "was-enrolled",
status: "WasEnrolled",
},
{
reason: "Qualified",
slug: "stays-enrolled",
branch: "control",
status: "Enrolled",
},
{
status: "Disqualified",
branch: "control",
slug: "recipe-mismatch",
reason: "NotTargeted",
},
{
slug: "invalid-recipe",
error_string: "invalid-recipe",
status: "Disqualified",
reason: "Error",
branch: "control",
},
{
branch: "control",
status: "Disqualified",
slug: "invalid-branch",
error_string: "invalid-branch",
reason: "Error",
},
{
slug: "invalid-feature",
status: "Disqualified",
branch: "control",
reason: "Error",
error_string: "invalid-feature",
},
{
reason: "Error",
status: "Disqualified",
branch: "control",
slug: "l10n-missing-locale",
error_string: "l10n-missing-locale",
},
{
slug: "l10n-missing-entry",
reason: "Error",
branch: "control",
status: "Disqualified",
error_string: "l10n-missing-entry",
},
{
slug: "enrolls",
reason: "Qualified",
branch: "control",
status: "Enrolled",
},
]);
await manager.unenroll("stays-enrolled");
await manager.unenroll("enrolls");
cleanupFeatures();
await cleanup();
});
add_task(async function test_updateRecipes_enrollmentStatus_notEnrolled() {
const features = [
new ExperimentFeature("test-feature-0", { variables: {} }),
new ExperimentFeature("test-feature-1", { variables: {} }),
new ExperimentFeature("test-feature-2", { variables: {} }),
new ExperimentFeature("test-feature-3", { variables: {} }),
new ExperimentFeature("test-feature-4", { variables: {} }),
new ExperimentFeature("test-feature-5", { variables: {} }),
new ExperimentFeature("test-feature-6", { variables: {} }),
new ExperimentFeature("test-feature-7", { variables: {} }),
new ExperimentFeature("test-feature-8", { variables: {} }),
];
const cleanupFeatures = NimbusTestUtils.addTestFeatures(...features);
function recipe(slug, featureId) {
return NimbusTestUtils.factories.recipe.withFeatureConfig(slug, {
featureId,
});
}
const recipes = [
{
...recipe("enrollment-paused", "test-feature-0"),
isEnrollmentPaused: true,
},
{
...recipe("no-match", "test-feature-1"),
targeting: "false",
},
{
...recipe("targeting-only", "test-feature-2"),
bucketConfig: {
...NimbusTestUtils.factories.recipe.bucketConfig,
count: 0,
},
},
{
...recipe("already-enrolled-rollout", "test-feature-3"),
isRollout: true,
},
recipe("already-enrolled-experiment", "test-feature-3"),
];
const { loader, manager, cleanup } = await setupTest();
await manager.enroll(
{ ...recipe("enrolled-rollout", "test-feature-3"), isRollout: true },
"force-enrollment"
);
await manager.enroll(
recipe("enrolled-experiment", "test-feature-3"),
"force-enrollment"
);
loader.remoteSettingsClients.experiments.get.resolves(recipes);
await loader.updateRecipes("timer");
Assert.deepEqual(
Glean.nimbusEvents.enrollmentStatus
.testGetValue("events")
?.map(ev => ev.extra),
[
{
reason: "OptIn",
status: "Enrolled",
branch: "control",
slug: "enrolled-rollout",
},
{
branch: "control",
reason: "OptIn",
status: "Enrolled",
slug: "enrolled-experiment",
},
{
slug: "enrollment-paused",
status: "NotEnrolled",
reason: "EnrollmentsPaused",
},
{
slug: "no-match",
status: "NotEnrolled",
reason: "NotTargeted",
},
{
slug: "targeting-only",
reason: "NotSelected",
status: "NotEnrolled",
},
{
conflict_slug: "enrolled-rollout",
slug: "already-enrolled-rollout",
reason: "FeatureConflict",
status: "NotEnrolled",
},
{
slug: "already-enrolled-experiment",
status: "NotEnrolled",
conflict_slug: "enrolled-experiment",
reason: "FeatureConflict",
},
]
);
await manager.unenroll("enrolled-experiment");
await manager.unenroll("enrolled-rollout");
cleanupFeatures();
await cleanup();
});
add_task(async function test_updateRecipesWithPausedEnrollment() {
const recipe = NimbusTestUtils.factories.recipe("foo", {
isEnrollmentPaused: true,
});
const { sandbox, loader, manager, cleanup } = await setupTest();
sandbox.spy(manager, "onRecipe");
sandbox.spy(manager, "_enroll");
loader.remoteSettingsClients.experiments.get.resolves([recipe]);
await loader.updateRecipes("test");
Assert.ok(
manager.onRecipe.calledOnceWith(recipe, "rs-loader", {
ok: true,
status: MatchStatus.ENROLLMENT_PAUSED,
}),
"Should call onRecipe with enrollments paused"
);
Assert.ok(
manager._enroll.notCalled,
"Should not call enroll for paused recipe"
);
await cleanup();
});
add_task(async function test_updateRecipesUnenrollsNotSeenRecipes() {
const { sandbox, loader, manager, cleanup } = await setupTest();
sandbox.spy(TelemetryEnvironment, "setExperimentActive");
sandbox.spy(TelemetryEnvironment, "setExperimentInactive");
sandbox.spy(manager, "updateEnrollment");
const recipe = NimbusTestUtils.factories.recipe("rollout", {
isRollout: true,
});
loader.remoteSettingsClients.experiments.get.resolves([recipe]);
await loader.updateRecipes();
Assert.ok(manager.store.get("rollout")?.active, "rollout is active");
Assert.ok(
TelemetryEnvironment.setExperimentActive.calledOnceWith("rollout"),
"set experiment as active"
);
Assert.equal(
Glean.nimbusEvents.enrollFailed.testGetValue("events"),
undefined,
"no enrollment failure events"
);
loader.remoteSettingsClients.experiments.get.resolves([]);
await loader.updateRecipes();
Assert.ok(
manager.updateEnrollment.calledOnceWith(
sinon.match({ slug: "rollout" }),
undefined,
"rs-loader",
{ ok: true, status: MatchStatus.NOT_SEEN }
),
"Should call updateEnrollment with recipe-not-seen"
);
Assert.ok(!manager.store.get("rollout").active, "rollout is inactive");
Assert.equal(
manager.store.get("rollout").unenrollReason,
"recipe-not-seen",
"rollout unenrolled for correct reason"
);
Assert.ok(
TelemetryEnvironment.setExperimentInactive.calledOnceWith("rollout"),
"set experiment as active"
);
Assert.equal(
Glean.nimbusEvents.unenrollFailed.testGetValue("events"),
undefined,
"No unenrollment failure events"
);
Assert.deepEqual(
Glean.nimbusEvents.unenrollment.testGetValue("events").map(e => e.extra),
[
{
experiment: "rollout",
branch: "control",
reason: "recipe-not-seen",
},
],
"One unenrollment event"
);
TelemetryTestUtils.assertEvents(
[
{
value: "rollout",
extra: {
reason: "recipe-not-seen",
},
},
],
{
category: "normandy",
method: "unenroll",
object: "nimbus_experiment",
},
{
clear: true,
}
);
await cleanup();
});
add_task(async function test_updateRecipesUnenrollsTargetingMismatch() {
const { loader, manager, cleanup } = await setupTest();
const recipe = NimbusTestUtils.factories.recipe("only-once", {
targeting: "!(experiment.slug in activeExperiments)",
});
loader.remoteSettingsClients.experiments.get.resolves([recipe]);
await loader.updateRecipes();
Assert.ok(manager.store.get("only-once")?.active, "Enrolled");
await loader.updateRecipes();
Assert.ok(!manager.store.get("only-once").active, "Unenrolled");
Assert.equal(
manager.store.get("only-once").unenrollReason,
"targeting-mismatch",
"Unenroll reason matches"
);
await cleanup();
});
add_task(async function testUnenrollsFirst() {
const e1 = NimbusTestUtils.factories.recipe("e1");
const e2 = NimbusTestUtils.factories.recipe("e2");
const e3 = NimbusTestUtils.factories.recipe("e3");
const r1 = NimbusTestUtils.factories.recipe("r1", { isRollout: true });
const r2 = NimbusTestUtils.factories.recipe("r2", { isRollout: true });
const r3 = NimbusTestUtils.factories.recipe("r3", { isRollout: true });
const { loader, manager, cleanup } = await setupTest();
loader.remoteSettingsClients.experiments.get.resolves([
e1,
e2,
e3,
r1,
r2,
r3,
]);
// e1 and r1 should enroll. The rest cannot enroll because of feature conflicts.
await loader.updateRecipes();
assertEnrollments(manager.store, ["e1", "r1"], []);
// No change.
await loader.updateRecipes();
assertEnrollments(manager.store, ["e1", "r1"], []);
// Remove e1. e1 should unenroll. e2 should enroll.
loader.remoteSettingsClients.experiments.get.resolves([e2, e3, r1, r2, r3]);
await loader.updateRecipes();
assertEnrollments(manager.store, ["e2", "r1"], ["e1"]);
// Remove r1. r1 should unenroll. r2 should enroll.
loader.remoteSettingsClients.experiments.get.resolves([e2, e3, r2, r3]);
await loader.updateRecipes();
assertEnrollments(manager.store, ["e2", "r2"], ["e1", "r1"]);
// Remove e2 and r2. e2 and r2 should unenroll. e3 and r3 should enroll.
loader.remoteSettingsClients.experiments.get.resolves([e3, r3]);
await loader.updateRecipes();
assertEnrollments(manager.store, ["e3", "r3"], ["e1", "e2", "r1", "r2"]);
await manager.unenroll("e3");
await manager.unenroll("r3");
await cleanup();
});