2225 lines
57 KiB
JavaScript
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();
|
|
});
|