summaryrefslogtreecommitdiffstats
path: root/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js')
-rw-r--r--toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js929
1 files changed, 929 insertions, 0 deletions
diff --git a/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js b/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js
new file mode 100644
index 0000000000..9fe8bafef6
--- /dev/null
+++ b/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js
@@ -0,0 +1,929 @@
+"use strict";
+
+const { ExperimentFakes } = ChromeUtils.import(
+ "resource://testing-common/NimbusTestUtils.jsm"
+);
+const { FirstStartup } = ChromeUtils.importESModule(
+ "resource://gre/modules/FirstStartup.sys.mjs"
+);
+const { NimbusFeatures } = ChromeUtils.import(
+ "resource://nimbus/ExperimentAPI.jsm"
+);
+const { PanelTestProvider } = ChromeUtils.import(
+ "resource://activity-stream/lib/PanelTestProvider.jsm"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+const { TelemetryEvents } = ChromeUtils.import(
+ "resource://normandy/lib/TelemetryEvents.jsm"
+);
+
+add_setup(async function setup() {
+ do_get_profile();
+ Services.fog.initializeFOG();
+});
+
+add_task(async function test_updateRecipes_activeExperiments() {
+ const manager = ExperimentFakes.manager();
+ const sandbox = sinon.createSandbox();
+ const recipe = ExperimentFakes.recipe("foo");
+ const loader = ExperimentFakes.rsLoader();
+ loader.manager = manager;
+ const PASS_FILTER_RECIPE = ExperimentFakes.recipe("foo", {
+ targeting: `"${recipe.slug}" in activeExperiments`,
+ });
+ const onRecipe = sandbox.stub(manager, "onRecipe");
+ sinon.stub(loader.remoteSettingsClient, "get").resolves([PASS_FILTER_RECIPE]);
+ sandbox.stub(manager.store, "ready").resolves();
+ sandbox.stub(manager.store, "getAllActive").returns([recipe]);
+
+ await loader.init();
+
+ ok(onRecipe.calledOnce, "Should match active experiments");
+});
+
+add_task(async function test_updateRecipes_isFirstRun() {
+ const manager = ExperimentFakes.manager();
+ const sandbox = sinon.createSandbox();
+ const recipe = ExperimentFakes.recipe("foo");
+ const loader = ExperimentFakes.rsLoader();
+ loader.manager = manager;
+ const PASS_FILTER_RECIPE = { ...recipe, targeting: "isFirstStartup" };
+ const onRecipe = sandbox.stub(manager, "onRecipe");
+ sinon.stub(loader.remoteSettingsClient, "get").resolves([PASS_FILTER_RECIPE]);
+ sandbox.stub(manager.store, "ready").resolves();
+ sandbox.stub(manager.store, "getAllActive").returns([recipe]);
+
+ // Pretend to be in the first startup
+ FirstStartup._state = FirstStartup.IN_PROGRESS;
+ await loader.init();
+
+ Assert.ok(onRecipe.calledOnce, "Should match first run");
+});
+
+add_task(async function test_updateRecipes_invalidFeatureId() {
+ const manager = ExperimentFakes.manager();
+ const sandbox = sinon.createSandbox();
+ const loader = ExperimentFakes.rsLoader();
+ loader.manager = manager;
+
+ const badRecipe = ExperimentFakes.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 onRecipe = sandbox.stub(manager, "onRecipe");
+ sinon.stub(loader.remoteSettingsClient, "get").resolves([badRecipe]);
+ sandbox.stub(manager.store, "ready").resolves();
+ sandbox.stub(manager.store, "getAllActive").returns([]);
+
+ await loader.init();
+ ok(onRecipe.notCalled, "No recipes");
+});
+
+add_task(async function test_updateRecipes_invalidFeatureValue() {
+ const manager = ExperimentFakes.manager();
+ const sandbox = sinon.createSandbox();
+ const loader = ExperimentFakes.rsLoader();
+ loader.manager = manager;
+
+ const badRecipe = ExperimentFakes.recipe("foo", {
+ branches: [
+ {
+ slug: "control",
+ ratio: 1,
+ features: [
+ {
+ featureId: "spotlight",
+ value: {
+ id: "test-spotlight-invalid-1",
+ },
+ },
+ ],
+ },
+ {
+ slug: "treatment",
+ ratio: 1,
+ features: [
+ {
+ featureId: "spotlight",
+ value: {
+ id: "test-spotlight-invalid-2",
+ },
+ },
+ ],
+ },
+ ],
+ });
+
+ const onRecipe = sandbox.stub(manager, "onRecipe");
+ sinon.stub(loader.remoteSettingsClient, "get").resolves([badRecipe]);
+ sandbox.stub(manager.store, "ready").resolves();
+ sandbox.stub(manager.store, "getAllActive").returns([]);
+
+ await loader.init();
+ ok(onRecipe.notCalled, "No recipes");
+});
+
+add_task(async function test_updateRecipes_invalidRecipe() {
+ const manager = ExperimentFakes.manager();
+ const sandbox = sinon.createSandbox();
+ const loader = ExperimentFakes.rsLoader();
+ loader.manager = manager;
+
+ const badRecipe = ExperimentFakes.recipe("foo");
+ delete badRecipe.slug;
+
+ const onRecipe = sandbox.stub(manager, "onRecipe");
+ sinon.stub(loader.remoteSettingsClient, "get").resolves([badRecipe]);
+ sandbox.stub(manager.store, "ready").resolves();
+ sandbox.stub(manager.store, "getAllActive").returns([]);
+
+ await loader.init();
+ ok(onRecipe.notCalled, "No recipes");
+});
+
+add_task(async function test_updateRecipes_invalidRecipeAfterUpdate() {
+ Services.fog.testResetFOG();
+
+ const manager = ExperimentFakes.manager();
+ const loader = ExperimentFakes.rsLoader();
+ loader.manager = manager;
+
+ const recipe = ExperimentFakes.recipe("foo");
+ const badRecipe = { ...recipe };
+ delete badRecipe.branches;
+
+ sinon.stub(loader, "setTimer");
+ sinon.stub(manager, "onRecipe");
+ sinon.stub(manager, "onFinalize");
+
+ sinon.stub(loader.remoteSettingsClient, "get").resolves([recipe]);
+ sinon.stub(manager.store, "ready").resolves();
+ sinon.spy(loader, "updateRecipes");
+
+ await loader.init();
+
+ ok(loader.updateRecipes.calledOnce, "should call .updateRecipes");
+ equal(loader.manager.onRecipe.callCount, 1, "should call .onRecipe once");
+ ok(
+ loader.manager.onRecipe.calledWith(recipe, "rs-loader"),
+ "should call .onRecipe with argument data"
+ );
+ equal(loader.manager.onFinalize.callCount, 1, "should call .onFinalize once");
+ ok(
+ loader.manager.onFinalize.calledWith("rs-loader", {
+ recipeMismatches: [],
+ invalidRecipes: [],
+ invalidBranches: new Map(),
+ invalidFeatures: new Map(),
+ validationEnabled: true,
+ }),
+ "should call .onFinalize with no mismatches or invalid recipes"
+ );
+
+ info("Replacing recipe with an invalid one");
+
+ loader.remoteSettingsClient.get.resolves([badRecipe]);
+
+ await loader.updateRecipes("timer");
+ equal(
+ loader.manager.onRecipe.callCount,
+ 1,
+ "should not have called .onRecipe again"
+ );
+ equal(
+ loader.manager.onFinalize.callCount,
+ 2,
+ "should have called .onFinalize again"
+ );
+
+ ok(
+ loader.manager.onFinalize.secondCall.calledWith("rs-loader", {
+ recipeMismatches: [],
+ invalidRecipes: ["foo"],
+ invalidBranches: new Map(),
+ invalidFeatures: new Map(),
+ validationEnabled: true,
+ }),
+ "should call .onFinalize with an invalid recipe"
+ );
+});
+
+add_task(async function test_updateRecipes_invalidBranchAfterUpdate() {
+ const message = await PanelTestProvider.getMessages().then(msgs =>
+ msgs.find(m => m.id === "SPOTLIGHT_MESSAGE_93")
+ );
+
+ const manager = ExperimentFakes.manager();
+ const loader = ExperimentFakes.rsLoader();
+ loader.manager = manager;
+
+ const recipe = ExperimentFakes.recipe("foo", {
+ 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;
+
+ sinon.stub(loader, "setTimer");
+ sinon.stub(manager, "onRecipe");
+ sinon.stub(manager, "onFinalize");
+
+ sinon.stub(loader.remoteSettingsClient, "get").resolves([recipe]);
+ sinon.stub(manager.store, "ready").resolves();
+ sinon.spy(loader, "updateRecipes");
+
+ await loader.init();
+
+ ok(loader.updateRecipes.calledOnce, "should call .updateRecipes");
+ equal(loader.manager.onRecipe.callCount, 1, "should call .onRecipe once");
+ ok(
+ loader.manager.onRecipe.calledWith(recipe, "rs-loader"),
+ "should call .onRecipe with argument data"
+ );
+ equal(loader.manager.onFinalize.callCount, 1, "should call .onFinalize once");
+ ok(
+ loader.manager.onFinalize.calledWith("rs-loader", {
+ recipeMismatches: [],
+ invalidRecipes: [],
+ invalidBranches: new Map(),
+ invalidFeatures: new Map(),
+ validationEnabled: true,
+ }),
+ "should call .onFinalize with no mismatches or invalid recipes"
+ );
+
+ info("Replacing recipe with an invalid one");
+
+ loader.remoteSettingsClient.get.resolves([badRecipe]);
+
+ await loader.updateRecipes("timer");
+ equal(
+ loader.manager.onRecipe.callCount,
+ 1,
+ "should not have called .onRecipe again"
+ );
+ equal(
+ loader.manager.onFinalize.callCount,
+ 2,
+ "should have called .onFinalize again"
+ );
+
+ ok(
+ loader.manager.onFinalize.secondCall.calledWith("rs-loader", {
+ recipeMismatches: [],
+ invalidRecipes: [],
+ invalidBranches: new Map([["foo", [badRecipe.branches[0].slug]]]),
+ invalidFeatures: new Map(),
+ validationEnabled: true,
+ }),
+ "should call .onFinalize with an invalid branch"
+ );
+});
+
+add_task(async function test_updateRecipes_simpleFeatureInvalidAfterUpdate() {
+ const loader = ExperimentFakes.rsLoader();
+ const manager = loader.manager;
+
+ const recipe = ExperimentFakes.recipe("foo");
+ const badRecipe = ExperimentFakes.recipe("foo", {
+ 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,
+ };
+
+ sinon.spy(loader, "updateRecipes");
+ sinon.spy(loader, "_generateVariablesOnlySchema");
+ sinon.stub(loader, "setTimer");
+ sinon.stub(loader.remoteSettingsClient, "get").resolves([recipe]);
+
+ sinon.stub(manager, "onFinalize");
+ sinon.stub(manager, "onRecipe");
+ sinon.stub(manager.store, "ready").resolves();
+
+ await loader.init();
+ ok(manager.onRecipe.calledOnce, "should call .updateRecipes");
+ equal(loader.manager.onRecipe.callCount, 1, "should call .onRecipe once");
+ ok(
+ loader.manager.onRecipe.calledWith(recipe, "rs-loader"),
+ "should call .onRecipe with argument data"
+ );
+ equal(loader.manager.onFinalize.callCount, 1, "should call .onFinalize once");
+ ok(
+ loader.manager.onFinalize.calledWith("rs-loader", {
+ recipeMismatches: [],
+ invalidRecipes: [],
+ invalidBranches: new Map(),
+ invalidFeatures: new Map(),
+ validationEnabled: true,
+ }),
+ "should call .onFinalize with nomismatches or invalid recipes"
+ );
+
+ ok(
+ loader._generateVariablesOnlySchema.calledOnce,
+ "Should have generated a schema for testFeature"
+ );
+
+ Assert.deepEqual(
+ loader._generateVariablesOnlySchema.returnValues[0],
+ EXPECTED_SCHEMA,
+ "should have generated a schema with three fields"
+ );
+
+ info("Replacing recipe with an invalid one");
+
+ loader.remoteSettingsClient.get.resolves([badRecipe]);
+
+ await loader.updateRecipes("timer");
+ equal(
+ manager.onRecipe.callCount,
+ 1,
+ "should not have called .onRecipe again"
+ );
+ equal(
+ manager.onFinalize.callCount,
+ 2,
+ "should have called .onFinalize again"
+ );
+
+ ok(
+ loader.manager.onFinalize.secondCall.calledWith("rs-loader", {
+ recipeMismatches: [],
+ invalidRecipes: [],
+ invalidBranches: new Map([["foo", [badRecipe.branches[0].slug]]]),
+ invalidFeatures: new Map(),
+ validationEnabled: true,
+ }),
+ "should call .onFinalize with an invalid branch"
+ );
+});
+
+add_task(async function test_updateRecipes_validationTelemetry() {
+ TelemetryEvents.init();
+
+ Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ /* clear = */ true
+ );
+
+ const invalidRecipe = ExperimentFakes.recipe("invalid-recipe");
+ delete invalidRecipe.channel;
+
+ const invalidBranch = ExperimentFakes.recipe("invalid-branch");
+ invalidBranch.branches[0].features[0].value.testInt = "hello";
+ invalidBranch.branches[1].features[0].value.testInt = "world";
+
+ const invalidFeature = ExperimentFakes.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: invalidFeature.branches[0].features.map(feature => ({
+ feature: feature.featureId,
+ })),
+ callCount: 2,
+ },
+ ];
+
+ 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 loader = ExperimentFakes.rsLoader();
+ const manager = loader.manager;
+
+ sinon.stub(loader, "setTimer");
+ sinon.stub(loader.remoteSettingsClient, "get").resolves([recipe]);
+
+ sinon.stub(manager, "onRecipe");
+ sinon.stub(manager.store, "ready").resolves();
+ sinon.stub(manager.store, "getAllActive").returns([]);
+ sinon.stub(manager.store, "getAllRollouts").returns([]);
+
+ const telemetrySpy = sinon.spy(manager, "sendValidationFailedTelemetry");
+
+ await loader.init();
+
+ Assert.equal(
+ telemetrySpy.callCount,
+ callCount,
+ `Should call sendValidationFailedTelemetry ${callCount} times for reason ${reason}`
+ );
+
+ const gleanEvents = Glean.nimbusEvents.validationFailed
+ .testGetValue()
+ .map(event => {
+ event = { ...event };
+ // We do not care about the timestamp.
+ delete event.timestamp;
+ return event;
+ });
+
+ const expectedGleanEvents = events.map(event => ({
+ category: "nimbus_events",
+ name: "validation_failed",
+ extra: {
+ 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, {
+ clear: true,
+ });
+
+ Services.fog.testResetFOG();
+ }
+});
+
+add_task(async function test_updateRecipes_validationDisabled() {
+ Services.prefs.setBoolPref("nimbus.validation.enabled", false);
+
+ const invalidRecipe = ExperimentFakes.recipe("invalid-recipe");
+ delete invalidRecipe.channel;
+
+ const invalidBranch = ExperimentFakes.recipe("invalid-branch");
+ invalidBranch.branches[0].features[0].value.testInt = "hello";
+ invalidBranch.branches[1].features[0].value.testInt = "world";
+
+ const invalidFeature = ExperimentFakes.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 loader = ExperimentFakes.rsLoader();
+ const manager = loader.manager;
+
+ sinon.stub(loader, "setTimer");
+ sinon.stub(loader.remoteSettingsClient, "get").resolves([recipe]);
+
+ sinon.stub(manager, "onRecipe");
+ sinon.stub(manager.store, "ready").resolves();
+ sinon.stub(manager.store, "getAllActive").returns([]);
+ sinon.stub(manager.store, "getAllRollouts").returns([]);
+
+ const finalizeStub = sinon.stub(manager, "onFinalize");
+ const telemetrySpy = sinon.spy(manager, "sendValidationFailedTelemetry");
+
+ await loader.init();
+
+ Assert.equal(
+ telemetrySpy.callCount,
+ 0,
+ "Should not send validation failed telemetry"
+ );
+ Assert.ok(
+ finalizeStub.calledWith("rs-loader", {
+ recipeMismatches: [],
+ invalidRecipes: [],
+ invalidBranches: new Map(),
+ invalidFeatures: new Map(),
+ validationEnabled: false,
+ }),
+ "should call .onFinalize with no validation issues"
+ );
+ }
+ Services.prefs.clearUserPref("nimbus.validation.enabled");
+});
+
+add_task(async function test_updateRecipes_appId() {
+ const loader = ExperimentFakes.rsLoader();
+ const manager = loader.manager;
+
+ const recipe = ExperimentFakes.recipe("background-task-recipe", {
+ branches: [
+ {
+ slug: "control",
+ ratio: 1,
+ features: [
+ {
+ featureId: "backgroundTaskMessage",
+ value: {},
+ },
+ ],
+ },
+ ],
+ });
+
+ sinon.stub(loader, "setTimer");
+ sinon.stub(loader.remoteSettingsClient, "get").resolves([recipe]);
+
+ sinon.stub(manager, "onRecipe");
+ sinon.stub(manager, "onFinalize");
+ sinon.stub(manager.store, "ready").resolves();
+
+ info("Testing updateRecipes() with the default application ID");
+ await loader.init();
+
+ Assert.equal(manager.onRecipe.callCount, 0, ".onRecipe was never called");
+ Assert.ok(
+ manager.onFinalize.calledWith("rs-loader", {
+ recipeMismatches: [],
+ invalidRecipes: [],
+ invalidBranches: new Map(),
+ invalidFeatures: new Map(),
+ validationEnabled: true,
+ }),
+ "Should call .onFinalize with no validation issues"
+ );
+
+ info("Testing updateRecipes() with a custom application ID");
+
+ Services.prefs.setStringPref(
+ "nimbus.appId",
+ "firefox-desktop-background-task"
+ );
+
+ await loader.updateRecipes();
+ Assert.ok(
+ manager.onRecipe.calledWith(recipe, "rs-loader"),
+ `.onRecipe called with ${recipe.slug}`
+ );
+
+ Assert.ok(
+ manager.onFinalize.calledWith("rs-loader", {
+ recipeMismatches: [],
+ invalidRecipes: [],
+ invalidBranches: new Map(),
+ invalidFeatures: new Map(),
+ validationEnabled: true,
+ }),
+ "Should call .onFinalize with no validation issues"
+ );
+
+ Services.prefs.clearUserPref("nimbus.appId");
+});
+
+add_task(async function test_updateRecipes_withPropNotInManifest() {
+ // Need to randomize the slug so subsequent test runs don't skip enrollment
+ // due to a conflicting slug
+ const PASS_FILTER_RECIPE = ExperimentFakes.recipe("foo" + Math.random(), {
+ arguments: {},
+ 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 loader = ExperimentFakes.rsLoader();
+ sinon.stub(loader.remoteSettingsClient, "get").resolves([PASS_FILTER_RECIPE]);
+ sinon.stub(loader.manager, "onRecipe").resolves();
+ sinon.stub(loader.manager, "onFinalize");
+
+ await loader.init();
+
+ ok(
+ loader.manager.onRecipe.calledWith(PASS_FILTER_RECIPE, "rs-loader"),
+ "should call .onRecipe with this recipe"
+ );
+ equal(loader.manager.onRecipe.callCount, 1, "should only call onRecipe once");
+});
+
+add_task(async function test_updateRecipes_recipeAppId() {
+ const loader = ExperimentFakes.rsLoader();
+ const manager = loader.manager;
+
+ const recipe = ExperimentFakes.recipe("mobile-experiment", {
+ appId: "org.mozilla.firefox",
+ branches: [
+ {
+ slug: "control",
+ ratio: 1,
+ features: [
+ {
+ featureId: "mobile-feature",
+ value: {
+ enabled: true,
+ },
+ },
+ ],
+ },
+ ],
+ });
+
+ sinon.stub(loader, "setTimer");
+ sinon.stub(loader.remoteSettingsClient, "get").resolves([recipe]);
+
+ sinon.stub(manager, "onRecipe");
+ sinon.stub(manager, "onFinalize");
+ sinon.stub(manager.store, "ready").resolves();
+
+ await loader.init();
+
+ Assert.equal(manager.onRecipe.callCount, 0, ".onRecipe was never called");
+ Assert.ok(
+ manager.onFinalize.calledWith("rs-loader", {
+ recipeMismatches: [],
+ invalidRecipes: [],
+ invalidBranches: new Map(),
+ invalidFeatures: new Map(),
+ validationEnabled: true,
+ }),
+ "Should call .onFinalize with no validation issues"
+ );
+});
+
+add_task(async function test_updateRecipes_featureValidationOptOut() {
+ const invalidTestRecipe = ExperimentFakes.recipe("invalid-recipe", {
+ branches: [
+ {
+ slug: "control",
+ ratio: 1,
+ features: [
+ {
+ featureId: "testFeature",
+ value: {
+ enabled: "true",
+ testInt: false,
+ },
+ },
+ ],
+ },
+ ],
+ });
+
+ const message = await PanelTestProvider.getMessages().then(msgs =>
+ msgs.find(m => m.id === "SPOTLIGHT_MESSAGE_93")
+ );
+ delete message.template;
+
+ const invalidMsgRecipe = ExperimentFakes.recipe("invalid-recipe", {
+ branches: [
+ {
+ slug: "control",
+ ratio: 1,
+ features: [
+ {
+ featureId: "spotlight",
+ value: message,
+ },
+ ],
+ },
+ ],
+ });
+
+ for (const invalidRecipe of [invalidTestRecipe, invalidMsgRecipe]) {
+ const optOutRecipe = {
+ ...invalidMsgRecipe,
+ slug: "optout-recipe",
+ featureValidationOptOut: true,
+ };
+
+ const loader = ExperimentFakes.rsLoader();
+ const manager = loader.manager;
+
+ sinon.stub(loader, "setTimer");
+ sinon
+ .stub(loader.remoteSettingsClient, "get")
+ .resolves([invalidRecipe, optOutRecipe]);
+
+ sinon.stub(manager, "onRecipe");
+ sinon.stub(manager, "onFinalize");
+ sinon.stub(manager.store, "ready").resolves();
+ sinon.stub(manager.store, "getAllActive").returns([]);
+ sinon.stub(manager.store, "getAllRollouts").returns([]);
+
+ await loader.init();
+ ok(
+ manager.onRecipe.calledOnceWith(optOutRecipe, "rs-loader"),
+ "should call .onRecipe for the opt-out recipe"
+ );
+ ok(
+ manager.onFinalize.calledOnceWith("rs-loader", {
+ recipeMismatches: [],
+ invalidRecipes: [],
+ invalidBranches: new Map([[invalidRecipe.slug, ["control"]]]),
+ invalidFeatures: new Map(),
+ validationEnabled: true,
+ }),
+ "should call .onFinalize with only one invalid recipe"
+ );
+ }
+});
+
+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 = ExperimentFakes.recipe("recipe", {
+ branches: [
+ {
+ slug: "control",
+ ratio: 1,
+ features: [
+ {
+ featureId: "bogus",
+ value: {
+ bogus: "bogus",
+ },
+ },
+ ],
+ },
+ ],
+ targeting: "false",
+ });
+
+ const loader = ExperimentFakes.rsLoader();
+ const manager = loader.manager;
+
+ sinon.stub(loader, "setTimer");
+ sinon.stub(loader.remoteSettingsClient, "get").resolves([recipe]);
+
+ sinon.stub(manager, "onRecipe");
+ sinon.stub(manager, "onFinalize");
+ sinon.stub(manager.store, "ready").resolves();
+ sinon.stub(manager.store, "getAllActive").returns([]);
+ sinon.stub(manager.store, "getAllRollouts").returns([]);
+
+ const telemetrySpy = sinon.stub(manager, "sendValidationFailedTelemetry");
+ const targetingSpy = sinon.spy(loader, "checkTargeting");
+
+ await loader.init();
+ ok(targetingSpy.calledOnce, "Should have checked targeting for recipe");
+ ok(
+ !(await targetingSpy.returnValues[0]),
+ "Targeting should not have matched"
+ );
+ ok(manager.onRecipe.notCalled, "should not call .onRecipe for the recipe");
+ ok(
+ telemetrySpy.notCalled,
+ "Should not have submitted validation failed telemetry"
+ );
+});