diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/components/nimbus/test/unit/test_localization.js | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/nimbus/test/unit/test_localization.js')
-rw-r--r-- | toolkit/components/nimbus/test/unit/test_localization.js | 1401 |
1 files changed, 1401 insertions, 0 deletions
diff --git a/toolkit/components/nimbus/test/unit/test_localization.js b/toolkit/components/nimbus/test/unit/test_localization.js new file mode 100644 index 0000000000..1e950941a3 --- /dev/null +++ b/toolkit/components/nimbus/test/unit/test_localization.js @@ -0,0 +1,1401 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { ExperimentAPI, _ExperimentFeature: ExperimentFeature } = + ChromeUtils.importESModule("resource://nimbus/ExperimentAPI.sys.mjs"); +const { ExperimentFakes, ExperimentTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); +const { TelemetryEvents } = ChromeUtils.importESModule( + "resource://normandy/lib/TelemetryEvents.sys.mjs" +); + +const LOCALIZATIONS = { + "en-US": { + foo: "localized foo text", + qux: "localized qux text", + grault: "localized grault text", + waldo: "localized waldo text", + }, +}; + +const DEEPLY_NESTED_VALUE = { + foo: { + $l10n: { + id: "foo", + comment: "foo comment", + text: "original foo text", + }, + }, + bar: { + qux: { + $l10n: { + id: "qux", + comment: "qux comment", + text: "original qux text", + }, + }, + quux: { + grault: { + $l10n: { + id: "grault", + comment: "grault comment", + text: "orginal grault text", + }, + }, + garply: "original garply text", + }, + corge: "original corge text", + }, + baz: "original baz text", + waldo: [ + { + $l10n: { + id: "waldo", + comment: "waldo comment", + text: "original waldo text", + }, + }, + ], +}; + +const LOCALIZED_DEEPLY_NESTED_VALUE = { + foo: "localized foo text", + bar: { + qux: "localized qux text", + quux: { + grault: "localized grault text", + garply: "original garply text", + }, + corge: "original corge text", + }, + baz: "original baz text", + waldo: ["localized waldo text"], +}; + +const FEATURE_ID = "testfeature1"; +const TEST_PREF_BRANCH = "testfeature1."; +const FEATURE = new ExperimentFeature(FEATURE_ID, { + isEarlyStartup: false, + variables: { + foo: { + type: "string", + fallbackPref: `${TEST_PREF_BRANCH}foo`, + }, + bar: { + type: "json", + fallbackPref: `${TEST_PREF_BRANCH}bar`, + }, + baz: { + type: "string", + fallbackPref: `${TEST_PREF_BRANCH}baz`, + }, + waldo: { + type: "json", + fallbackPref: `${TEST_PREF_BRANCH}waldo`, + }, + }, +}); + +/** + * Remove the experiment store. + */ +async function cleanupStore(store) { + // We need to call finalize first to ensure that any pending saves from + // JSONFile.saveSoon overwrite files on disk. + await store._store.finalize(); + await IOUtils.remove(store._store.path); +} + +function resetTelemetry() { + Services.fog.testResetFOG(); + Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + /* clear = */ true + ); +} + +add_setup(function setup() { + do_get_profile(); + + Services.fog.initializeFOG(); + TelemetryEvents.init(); + + registerCleanupFunction(ExperimentTestUtils.addTestFeatures(FEATURE)); + registerCleanupFunction(resetTelemetry); +}); + +add_task(async function test_schema() { + const recipe = ExperimentFakes.recipe("foo"); + + info("Testing recipe without a localizations entry"); + await ExperimentTestUtils.validateExperiment(recipe); + + info("Testing recipe with a 'null' localizations entry"); + await ExperimentTestUtils.validateExperiment({ + ...recipe, + localizations: null, + }); + + info("Testing recipe with a valid localizations entry"); + await ExperimentTestUtils.validateExperiment({ + ...recipe, + localizations: LOCALIZATIONS, + }); + + info("Testing recipe with an invalid localizations entry"); + await Assert.rejects( + ExperimentTestUtils.validateExperiment({ + ...recipe, + localizations: [], + }), + /Experiment foo not valid/ + ); +}); + +add_task(function test_substituteLocalizations() { + Assert.equal( + ExperimentFeature.substituteLocalizations("string", LOCALIZATIONS["en-US"]), + "string", + "String values should not be subsituted" + ); + + Assert.equal( + ExperimentFeature.substituteLocalizations( + { + $l10n: { + id: "foo", + comment: "foo comment", + text: "original foo text", + }, + }, + LOCALIZATIONS["en-US"] + ), + "localized foo text", + "$l10n objects should be substituted" + ); + + Assert.deepEqual( + ExperimentFeature.substituteLocalizations( + DEEPLY_NESTED_VALUE, + LOCALIZATIONS["en-US"] + ), + LOCALIZED_DEEPLY_NESTED_VALUE, + "Supports nested substitutions" + ); + + Assert.throws( + () => + ExperimentFeature.substituteLocalizations( + { + foo: { + $l10n: { + id: "BOGUS", + comment: "A variable with a missing id", + text: "Original text", + }, + }, + }, + LOCALIZATIONS["en-US"] + ), + ex => ex.reason === "l10n-missing-entry" + ); +}); + +add_task(async function test_getLocalizedValue() { + const sandbox = sinon.createSandbox(); + const manager = ExperimentFakes.manager(); + + sandbox.stub(ExperimentAPI, "_manager").get(() => manager); + sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); + + await manager.onStartup(); + await manager.store.ready(); + + const experiment = ExperimentFakes.recipe("experiment", { + branches: [ + { + slug: "control", + features: [ + { + featureId: FEATURE_ID, + value: DEEPLY_NESTED_VALUE, + }, + ], + }, + ], + localizations: LOCALIZATIONS, + }); + + const { enrollmentPromise, doExperimentCleanup } = + ExperimentFakes.enrollmentHelper(experiment); + await enrollmentPromise; + + const enrollment = manager.store.getExperimentForFeature(FEATURE_ID); + + Assert.deepEqual( + FEATURE._getLocalizedValue(enrollment), + LOCALIZED_DEEPLY_NESTED_VALUE, + "_getLocalizedValue() for all values" + ); + + Assert.deepEqual( + FEATURE._getLocalizedValue(enrollment, "foo"), + LOCALIZED_DEEPLY_NESTED_VALUE.foo, + "_getLocalizedValue() with a top-level localized variable" + ); + + Assert.deepEqual( + FEATURE._getLocalizedValue(enrollment, "bar"), + LOCALIZED_DEEPLY_NESTED_VALUE.bar, + "_getLocalizedValue() with a nested localization" + ); + + await doExperimentCleanup(); + await cleanupStore(manager.store); + sandbox.reset(); +}); + +add_task(async function test_getLocalizedValue_unenroll_missingEntry() { + resetTelemetry(); + + const sandbox = sinon.createSandbox(); + const manager = ExperimentFakes.manager(); + + sandbox.stub(ExperimentAPI, "_manager").get(() => manager); + sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); + + await manager.onStartup(); + await manager.store.ready(); + + const experiment = ExperimentFakes.recipe("experiment", { + branches: [ + { + slug: "control", + features: [ + { + featureId: FEATURE_ID, + value: { + bar: { + $l10n: { + id: "BOGUS", + comment: "Bogus localization", + text: "Original text", + }, + }, + }, + }, + ], + }, + ], + localizations: LOCALIZATIONS, + }); + + await ExperimentFakes.enrollmentHelper(experiment).enrollmentPromise; + + const enrollment = manager.store.getExperimentForFeature(FEATURE_ID); + + Assert.deepEqual( + FEATURE._getLocalizedValue(enrollment), + undefined, + "_getLocalizedValue() with a bogus localization" + ); + + Assert.equal( + manager.store.getExperimentForFeature(FEATURE_ID), + null, + "Experiment should be unenrolled" + ); + + const gleanEvents = Glean.nimbusEvents.unenrollment.testGetValue(); + Assert.equal(gleanEvents.length, 1, "Should be one unenrollment event"); + Assert.equal( + gleanEvents[0].extra.reason, + "l10n-missing-entry", + "Reason should match" + ); + Assert.equal( + gleanEvents[0].extra.experiment, + "experiment", + "Slug should match" + ); + + TelemetryTestUtils.assertEvents( + [ + { + value: "experiment", + extra: { reason: "l10n-missing-entry" }, + }, + ], + { + category: "normandy", + method: "unenroll", + object: "nimbus_experiment", + } + ); + + await cleanupStore(manager.store); + sandbox.reset(); +}); + +add_task(async function test_getLocalizedValue_unenroll_missingEntry() { + resetTelemetry(); + + const sandbox = sinon.createSandbox(); + const manager = ExperimentFakes.manager(); + + sandbox.stub(ExperimentAPI, "_manager").get(() => manager); + sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); + + await manager.onStartup(); + await manager.store.ready(); + + const experiment = ExperimentFakes.recipe("experiment", { + branches: [ + { + slug: "control", + features: [ + { + featureId: FEATURE_ID, + value: { + bar: { + $l10n: { + id: "BOGUS", + comment: "Bogus localization", + text: "Original text", + }, + }, + }, + }, + ], + }, + ], + localizations: { + "en-CA": {}, + }, + }); + + await ExperimentFakes.enrollmentHelper(experiment).enrollmentPromise; + + const enrollment = manager.store.getExperimentForFeature(FEATURE_ID); + + Assert.deepEqual( + FEATURE._getLocalizedValue(enrollment), + undefined, + "_getLocalizedValue() with a bogus localization" + ); + + Assert.equal( + manager.store.getExperimentForFeature(FEATURE_ID), + null, + "Experiment should be unenrolled" + ); + + const gleanEvents = Glean.nimbusEvents.unenrollment.testGetValue(); + Assert.equal(gleanEvents.length, 1, "Should be one unenrollment event"); + Assert.equal( + gleanEvents[0].extra.reason, + "l10n-missing-locale", + "Reason should match" + ); + Assert.equal( + gleanEvents[0].extra.experiment, + "experiment", + "Slug should match" + ); + + TelemetryTestUtils.assertEvents( + [ + { + value: "experiment", + extra: { reason: "l10n-missing-locale" }, + }, + ], + { + category: "normandy", + method: "unenroll", + object: "nimbus_experiment", + } + ); + + await cleanupStore(manager.store); + sandbox.reset(); +}); + +add_task(async function test_getVariables() { + const sandbox = sinon.createSandbox(); + const manager = ExperimentFakes.manager(); + + sandbox.stub(ExperimentAPI, "_manager").get(() => manager); + sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); + + await manager.onStartup(); + await manager.store.ready(); + + const experiment = ExperimentFakes.recipe("experiment", { + branches: [ + { + slug: "control", + features: [ + { + featureId: FEATURE_ID, + value: DEEPLY_NESTED_VALUE, + }, + ], + }, + ], + localizations: LOCALIZATIONS, + }); + + const { enrollmentPromise, doExperimentCleanup } = + ExperimentFakes.enrollmentHelper(experiment); + await enrollmentPromise; + + Assert.deepEqual( + FEATURE.getAllVariables(), + LOCALIZED_DEEPLY_NESTED_VALUE, + "getAllVariables() returns subsituted values" + ); + + Assert.equal( + FEATURE.getVariable("foo"), + LOCALIZED_DEEPLY_NESTED_VALUE.foo, + "getVariable() returns a top-level substituted value" + ); + + Assert.deepEqual( + FEATURE.getVariable("bar"), + LOCALIZED_DEEPLY_NESTED_VALUE.bar, + "getVariable() returns a nested substitution" + ); + + Assert.deepEqual( + FEATURE.getVariable("baz"), + DEEPLY_NESTED_VALUE.baz, + "getVariable() returns non-localized variables unmodified" + ); + + Assert.deepEqual( + FEATURE.getVariable("waldo"), + LOCALIZED_DEEPLY_NESTED_VALUE.waldo, + "getVariable() returns substitutions inside arrays" + ); + + await doExperimentCleanup(); + await cleanupStore(manager.store); + sandbox.reset(); +}); + +add_task(async function test_getVariables_fallback() { + const sandbox = sinon.createSandbox(); + const manager = ExperimentFakes.manager(); + + sandbox.stub(ExperimentAPI, "_manager").get(() => manager); + sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); + + await manager.onStartup(); + await manager.store.ready(); + + Services.prefs.setStringPref( + FEATURE.manifest.variables.foo.fallbackPref, + "fallback-foo-pref-value" + ); + Services.prefs.setStringPref( + FEATURE.manifest.variables.baz.fallbackPref, + "fallback-baz-pref-value" + ); + + const recipes = { + experiment: ExperimentFakes.recipe("experiment", { + branches: [ + { + slug: "control", + features: [ + { + featureId: FEATURE_ID, + value: { + foo: DEEPLY_NESTED_VALUE.foo, + }, + }, + ], + }, + ], + localizations: { + "en-US": { + foo: LOCALIZATIONS["en-US"].foo, + }, + }, + }), + + rollout: ExperimentFakes.recipe("rollout", { + isRollout: true, + branches: [ + { + slug: "control", + features: [ + { + featureId: FEATURE_ID, + value: { + bar: DEEPLY_NESTED_VALUE.bar, + }, + }, + ], + }, + ], + localizations: { + "en-US": { + qux: LOCALIZATIONS["en-US"].qux, + grault: LOCALIZATIONS["en-US"].grault, + }, + }, + }), + }; + + const cleanup = {}; + + Assert.deepEqual( + FEATURE.getAllVariables({ defaultValues: { waldo: ["default-value"] } }), + { + foo: "fallback-foo-pref-value", + bar: null, + baz: "fallback-baz-pref-value", + waldo: ["default-value"], + }, + "getAllVariables() returns only values from prefs and defaults" + ); + + Assert.equal( + FEATURE.getVariable("foo"), + "fallback-foo-pref-value", + "variable foo returned from prefs" + ); + Assert.equal( + FEATURE.getVariable("bar"), + undefined, + "variable bar returned from rollout" + ); + Assert.equal( + FEATURE.getVariable("baz"), + "fallback-baz-pref-value", + "variable baz returned from prefs" + ); + + // Enroll in the rollout. + { + const { enrollmentPromise, doExperimentCleanup } = + ExperimentFakes.enrollmentHelper(recipes.rollout); + await enrollmentPromise; + + cleanup.rollout = doExperimentCleanup; + } + + Assert.deepEqual( + FEATURE.getAllVariables({ defaultValues: { waldo: ["default-value"] } }), + { + foo: "fallback-foo-pref-value", + bar: LOCALIZED_DEEPLY_NESTED_VALUE.bar, + baz: "fallback-baz-pref-value", + waldo: ["default-value"], + }, + "getAllVariables() returns subsituted values from the rollout" + ); + + Assert.equal( + FEATURE.getVariable("foo"), + "fallback-foo-pref-value", + "variable foo returned from prefs" + ); + Assert.deepEqual( + FEATURE.getVariable("bar"), + LOCALIZED_DEEPLY_NESTED_VALUE.bar, + "variable bar returned from rollout" + ); + Assert.equal( + FEATURE.getVariable("baz"), + "fallback-baz-pref-value", + "variable baz returned from prefs" + ); + + // Enroll in the experiment. + { + const { enrollmentPromise, doExperimentCleanup } = + ExperimentFakes.enrollmentHelper(recipes.experiment); + await enrollmentPromise; + + cleanup.experiment = doExperimentCleanup; + } + + Assert.deepEqual( + FEATURE.getAllVariables({ defaultValues: { waldo: ["default-value"] } }), + { + foo: LOCALIZED_DEEPLY_NESTED_VALUE.foo, + bar: null, + baz: "fallback-baz-pref-value", + waldo: ["default-value"], + }, + "getAllVariables() returns subsituted values from the experiment" + ); + + Assert.equal( + FEATURE.getVariable("foo"), + LOCALIZED_DEEPLY_NESTED_VALUE.foo, + "variable foo returned from experiment" + ); + Assert.deepEqual( + FEATURE.getVariable("bar"), + LOCALIZED_DEEPLY_NESTED_VALUE.bar, + "variable bar returned from rollout" + ); + Assert.equal( + FEATURE.getVariable("baz"), + "fallback-baz-pref-value", + "variable baz returned from prefs" + ); + + // Unenroll from the rollout so we are only enrolled in an experiment. + await cleanup.rollout(); + + Assert.deepEqual( + FEATURE.getAllVariables({ defaultValues: { waldo: ["default-value"] } }), + { + foo: LOCALIZED_DEEPLY_NESTED_VALUE.foo, + bar: null, + baz: "fallback-baz-pref-value", + waldo: ["default-value"], + }, + "getAllVariables() returns substituted values from the experiment" + ); + + Assert.equal( + FEATURE.getVariable("foo"), + LOCALIZED_DEEPLY_NESTED_VALUE.foo, + "variable foo returned from experiment" + ); + Assert.equal( + FEATURE.getVariable("bar"), + undefined, + "variable bar is not set" + ); + Assert.equal( + FEATURE.getVariable("baz"), + "fallback-baz-pref-value", + "variable baz returned from prefs" + ); + + // Unenroll from experiment. We are enrolled in nothing. + await cleanup.experiment(); + + Assert.deepEqual( + FEATURE.getAllVariables({ defaultValues: { waldo: ["default-value"] } }), + { + foo: "fallback-foo-pref-value", + bar: null, + baz: "fallback-baz-pref-value", + waldo: ["default-value"], + }, + "getAllVariables() returns only values from prefs and defaults" + ); + + Assert.equal( + FEATURE.getVariable("foo"), + "fallback-foo-pref-value", + "variable foo returned from prefs" + ); + Assert.equal( + FEATURE.getVariable("bar"), + undefined, + "variable bar returned from rollout" + ); + Assert.equal( + FEATURE.getVariable("baz"), + "fallback-baz-pref-value", + "variable baz returned from prefs" + ); + + Services.prefs.clearUserPref(FEATURE.manifest.variables.foo.fallbackPref); + Services.prefs.clearUserPref(FEATURE.manifest.variables.baz.fallbackPref); + + await cleanupStore(manager.store); + sandbox.reset(); +}); + +add_task(async function test_getVariables_fallback_unenroll() { + resetTelemetry(); + + const sandbox = sinon.createSandbox(); + const manager = ExperimentFakes.manager(); + + sandbox.stub(ExperimentAPI, "_manager").get(() => manager); + sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); + + await manager.onStartup(); + await manager.store.ready(); + + Services.prefs.setStringPref( + FEATURE.manifest.variables.foo.fallbackPref, + "fallback-foo-pref-value" + ); + Services.prefs.setStringPref( + FEATURE.manifest.variables.bar.fallbackPref, + `"fallback-bar-pref-value"` + ); + Services.prefs.setStringPref( + FEATURE.manifest.variables.baz.fallbackPref, + "fallback-baz-pref-value" + ); + Services.prefs.setStringPref( + FEATURE.manifest.variables.waldo.fallbackPref, + JSON.stringify(["fallback-waldo-pref-value"]) + ); + + const recipes = [ + ExperimentFakes.recipe("experiment", { + branches: [ + { + slug: "control", + features: [ + { + featureId: FEATURE_ID, + value: { + foo: DEEPLY_NESTED_VALUE.foo, + }, + }, + ], + }, + ], + localizations: {}, + }), + + ExperimentFakes.recipe("rollout", { + isRollout: true, + branches: [ + { + slug: "control", + features: [ + { + featureId: FEATURE_ID, + value: { + bar: DEEPLY_NESTED_VALUE.bar, + }, + }, + ], + }, + ], + localizations: { + "en-US": {}, + }, + }), + ]; + + for (const recipe of recipes) { + await ExperimentFakes.enrollmentHelper(recipe).enrollmentPromise; + } + + Assert.deepEqual(FEATURE.getAllVariables(), { + foo: "fallback-foo-pref-value", + bar: "fallback-bar-pref-value", + baz: "fallback-baz-pref-value", + waldo: ["fallback-waldo-pref-value"], + }); + + Assert.equal( + manager.store.getExperimentForFeature(FEATURE_ID), + null, + "Experiment should be unenrolled" + ); + + Assert.equal( + manager.store.getRolloutForFeature(FEATURE_ID), + null, + "Rollout should be unenrolled" + ); + + const gleanEvents = Glean.nimbusEvents.unenrollment.testGetValue(); + Assert.equal(gleanEvents.length, 2, "Should be two unenrollment events"); + Assert.equal( + gleanEvents[0].extra.reason, + "l10n-missing-locale", + "Reason should match" + ); + Assert.equal( + gleanEvents[0].extra.experiment, + "experiment", + "Slug should match" + ); + Assert.equal( + gleanEvents[1].extra.reason, + "l10n-missing-entry", + "Reason should match" + ); + Assert.equal(gleanEvents[1].extra.experiment, "rollout", "Slug should match"); + + TelemetryTestUtils.assertEvents( + [ + { + value: "experiment", + extra: { reason: "l10n-missing-locale" }, + }, + { + value: "rollout", + extra: { reason: "l10n-missing-entry" }, + }, + ], + { + category: "normandy", + method: "unenroll", + object: "nimbus_experiment", + } + ); + + Services.prefs.clearUserPref(FEATURE.manifest.variables.foo.fallbackPref); + Services.prefs.clearUserPref(FEATURE.manifest.variables.bar.fallbackPref); + Services.prefs.clearUserPref(FEATURE.manifest.variables.baz.fallbackPref); + Services.prefs.clearUserPref(FEATURE.manifest.variables.waldo.fallbackPref); + + await cleanupStore(manager.store); + sandbox.reset(); +}); + +add_task(async function test_updateRecipes() { + const sandbox = sinon.createSandbox(); + const manager = ExperimentFakes.manager(); + const loader = ExperimentFakes.rsLoader(); + + loader.manager = manager; + sandbox.stub(ExperimentAPI, "_manager").get(() => manager); + sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); + sandbox.stub(manager, "onRecipe"); + + const recipe = ExperimentFakes.recipe("foo", { + branches: [ + { + slug: "control", + features: [ + { + featureId: FEATURE_ID, + value: DEEPLY_NESTED_VALUE, + }, + ], + ratio: 1, + }, + ], + localizations: LOCALIZATIONS, + }); + + await loader.init(); + + await manager.onStartup(); + await manager.store.ready(); + + sandbox.stub(loader.remoteSettingsClient, "get").resolves([recipe]); + await loader.updateRecipes(); + + Assert.ok(manager.onRecipe.calledOnce, "Enrolled"); + + await cleanupStore(manager.store); + sandbox.reset(); +}); + +async function test_updateRecipes_missingLocale({ + featureValidationOptOut = false, + validationEnabled = true, +} = {}) { + resetTelemetry(); + + const sandbox = sinon.createSandbox(); + const manager = ExperimentFakes.manager(); + const loader = ExperimentFakes.rsLoader(); + + loader.manager = manager; + sandbox.stub(ExperimentAPI, "_manager").get(() => manager); + sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); + sandbox.stub(manager, "onRecipe"); + sandbox.spy(manager, "onFinalize"); + + const recipe = ExperimentFakes.recipe("foo", { + branches: [ + { + slug: "control", + features: [ + { + featureId: FEATURE_ID, + value: DEEPLY_NESTED_VALUE, + }, + ], + ratio: 1, + }, + ], + localizations: {}, + featureValidationOptOut, + }); + + await loader.init(); + + await manager.onStartup(); + await manager.store.ready(); + + sandbox.stub(loader.remoteSettingsClient, "get").resolves([recipe]); + await loader.updateRecipes(); + + Assert.ok(!manager.onRecipe.called, "Did not enroll in the recipe"); + Assert.ok( + onFinalizeCalled(manager.onFinalize, "rs-loader", { + recipeMismatches: [], + invalidRecipes: [], + invalidBranches: new Map(), + invalidFeatures: new Map(), + missingLocale: ["foo"], + missingL10nIds: new Map(), + locale: "en-US", + validationEnabled, + }), + "should call .onFinalize with missing locale" + ); + + const gleanEvents = Glean.nimbusEvents.validationFailed.testGetValue(); + Assert.equal(gleanEvents.length, 1, "Should be one validationFailed event"); + Assert.equal( + gleanEvents[0].extra.experiment, + "foo", + "Experiment slug should match" + ); + Assert.equal( + gleanEvents[0].extra.reason, + "l10n-missing-locale", + "Reason should match" + ); + Assert.equal(gleanEvents[0].extra.locale, "en-US", "Locale should match"); + + TelemetryTestUtils.assertEvents( + [ + { + value: "foo", + }, + ], + { + category: "normandy", + method: "validationFailed", + object: "nimbus_experiment", + } + ); + + await cleanupStore(manager.store); + sandbox.reset(); +} + +add_task(test_updateRecipes_missingLocale); + +add_task(async function test_updateRecipes_missingEntry() { + resetTelemetry(); + + const sandbox = sinon.createSandbox(); + const manager = ExperimentFakes.manager(); + const loader = ExperimentFakes.rsLoader(); + + loader.manager = manager; + sandbox.stub(ExperimentAPI, "_manager").get(() => manager); + sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); + sandbox.stub(manager, "onRecipe"); + sandbox.spy(manager, "onFinalize"); + + const recipe = ExperimentFakes.recipe("foo", { + branches: [ + { + slug: "control", + features: [ + { + featureId: FEATURE_ID, + value: DEEPLY_NESTED_VALUE, + }, + ], + ratio: 1, + }, + ], + localizations: { + "en-US": {}, + }, + }); + + await loader.init(); + + await manager.onStartup(); + await manager.store.ready(); + + sandbox.stub(loader.remoteSettingsClient, "get").resolves([recipe]); + await loader.updateRecipes(); + + Assert.ok(!manager.onRecipe.called, "Did not enroll in the recipe"); + Assert.ok( + onFinalizeCalled(manager.onFinalize, "rs-loader", { + recipeMismatches: [], + invalidRecipes: [], + invalidBranches: new Map(), + invalidFeatures: new Map(), + missingLocale: [], + missingL10nIds: new Map([["foo", ["foo", "qux", "grault", "waldo"]]]), + locale: "en-US", + validationEnabled: true, + }), + "should call .onFinalize with missing locale" + ); + + const gleanEvents = Glean.nimbusEvents.validationFailed.testGetValue(); + Assert.equal(gleanEvents.length, 1, "Should be one validationFailed event"); + Assert.equal( + gleanEvents[0].extra.experiment, + "foo", + "Experiment slug should match" + ); + Assert.equal( + gleanEvents[0].extra.reason, + "l10n-missing-entry", + "Reason should match" + ); + Assert.equal( + gleanEvents[0].extra.l10n_ids, + "foo,qux,grault,waldo", + "Missing IDs should match" + ); + Assert.equal(gleanEvents[0].extra.locale, "en-US", "Locale should match"); + + TelemetryTestUtils.assertEvents( + [ + { + value: "foo", + extra: { + reason: "l10n-missing-entry", + locale: "en-US", + l10n_ids: "foo,qux,grault,waldo", + }, + }, + ], + { + category: "normandy", + method: "validationFailed", + object: "nimbus_experiment", + } + ); + + await cleanupStore(manager.store); + sandbox.reset(); +}); + +add_task(async function test_updateRecipes_validationDisabled_pref() { + resetTelemetry(); + + Services.prefs.setBoolPref("nimbus.validation.enabled", false); + + await test_updateRecipes_missingLocale({ validationEnabled: false }); + + Services.prefs.clearUserPref("nimbus.validation.enabled"); +}); + +add_task(async function test_updateRecipes_validationDisabled_flag() { + resetTelemetry(); + + await test_updateRecipes_missingLocale({ featureValidationOptOut: true }); +}); + +add_task(async function test_updateRecipes_unenroll_missingEntry() { + resetTelemetry(); + + const sandbox = sinon.createSandbox(); + const manager = ExperimentFakes.manager(); + const loader = ExperimentFakes.rsLoader(); + + loader.manager = manager; + sandbox.stub(ExperimentAPI, "_manager").get(() => manager); + sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); + sandbox.spy(manager, "onRecipe"); + sandbox.spy(manager, "onFinalize"); + sandbox.spy(manager, "unenroll"); + + const recipe = ExperimentFakes.recipe("foo", { + branches: [ + { + slug: "control", + features: [ + { + featureId: FEATURE_ID, + value: DEEPLY_NESTED_VALUE, + }, + ], + ratio: 1, + }, + ], + localizations: LOCALIZATIONS, + }); + + await loader.init(); + + await manager.onStartup(); + await manager.store.ready(); + + await ExperimentFakes.enrollmentHelper(recipe, { + source: "rs-loader", + }).enrollmentPromise; + Assert.ok( + !!manager.store.getExperimentForFeature(FEATURE_ID), + "Should be enrolled in the experiment" + ); + + const badRecipe = { ...recipe, localizations: { "en-US": {} } }; + + sandbox.stub(loader.remoteSettingsClient, "get").resolves([badRecipe]); + + await loader.updateRecipes(); + + Assert.ok( + onFinalizeCalled(manager.onFinalize, "rs-loader", { + recipeMismatches: [], + invalidRecipes: [], + invalidBranches: new Map(), + invalidFeatures: new Map(), + missingLocale: [], + missingL10nIds: new Map([ + [recipe.slug, ["foo", "qux", "grault", "waldo"]], + ]), + locale: "en-US", + validationEnabled: true, + }), + "should call .onFinalize with missing l10n entry" + ); + + Assert.ok(manager.unenroll.calledWith(recipe.slug, "l10n-missing-entry")); + + Assert.equal( + manager.store.getExperimentForFeature(FEATURE_ID), + null, + "Should no longer be enrolled in the experiment" + ); + + const unenrollEvents = Glean.nimbusEvents.unenrollment.testGetValue(); + Assert.equal(unenrollEvents.length, 1, "Should be one unenroll event"); + Assert.equal( + unenrollEvents[0].extra.experiment, + "foo", + "Experiment slug should match" + ); + Assert.equal( + unenrollEvents[0].extra.reason, + "l10n-missing-entry", + "Reason should match" + ); + + const validationFailedEvents = + Glean.nimbusEvents.validationFailed.testGetValue(); + Assert.equal( + validationFailedEvents.length, + 1, + "Should be one validation failed event" + ); + Assert.equal( + validationFailedEvents[0].extra.experiment, + "foo", + "Experiment slug should match" + ); + Assert.equal( + validationFailedEvents[0].extra.reason, + "l10n-missing-entry", + "Reason should match" + ); + Assert.equal( + validationFailedEvents[0].extra.l10n_ids, + "foo,qux,grault,waldo", + "Missing IDs should match" + ); + Assert.equal( + validationFailedEvents[0].extra.locale, + "en-US", + "Locale should match" + ); + + TelemetryTestUtils.assertEvents( + [ + { + value: "foo", + extra: { + reason: "l10n-missing-entry", + }, + }, + ], + { + category: "normandy", + method: "unenroll", + object: "nimbus_experiment", + }, + { clear: false } + ); + + TelemetryTestUtils.assertEvents( + [ + { + value: "foo", + extra: { + reason: "l10n-missing-entry", + l10n_ids: "foo,qux,grault,waldo", + locale: "en-US", + }, + }, + ], + { + category: "normandy", + method: "validationFailed", + object: "nimbus_experiment", + } + ); + + await cleanupStore(manager.store); + sandbox.reset(); +}); + +add_task(async function test_updateRecipes_unenroll_missingLocale() { + resetTelemetry(); + + const sandbox = sinon.createSandbox(); + const manager = ExperimentFakes.manager(); + const loader = ExperimentFakes.rsLoader(); + + loader.manager = manager; + sandbox.stub(ExperimentAPI, "_manager").get(() => manager); + sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); + sandbox.spy(manager, "onRecipe"); + sandbox.spy(manager, "onFinalize"); + sandbox.spy(manager, "unenroll"); + + const recipe = ExperimentFakes.recipe("foo", { + branches: [ + { + slug: "control", + features: [ + { + featureId: FEATURE_ID, + value: DEEPLY_NESTED_VALUE, + }, + ], + ratio: 1, + }, + ], + localizations: LOCALIZATIONS, + }); + + await loader.init(); + + await manager.onStartup(); + await manager.store.ready(); + + await ExperimentFakes.enrollmentHelper(recipe, { + source: "rs-loader", + }).enrollmentPromise; + Assert.ok( + !!manager.store.getExperimentForFeature(FEATURE_ID), + "Should be enrolled in the experiment" + ); + + const badRecipe = { + ...recipe, + localizations: {}, + }; + + sandbox.stub(loader.remoteSettingsClient, "get").resolves([badRecipe]); + + await loader.updateRecipes(); + + Assert.ok( + onFinalizeCalled(manager.onFinalize, "rs-loader", { + recipeMismatches: [], + invalidRecipes: [], + invalidBranches: new Map(), + invalidFeatures: new Map(), + missingLocale: ["foo"], + missingL10nIds: new Map(), + locale: "en-US", + validationEnabled: true, + }), + "should call .onFinalize with missing locale" + ); + + Assert.ok(manager.unenroll.calledWith(recipe.slug, "l10n-missing-locale")); + + Assert.equal( + manager.store.getExperimentForFeature(FEATURE_ID), + null, + "Should no longer be enrolled in the experiment" + ); + + const unenrollEvents = Glean.nimbusEvents.unenrollment.testGetValue(); + Assert.equal(unenrollEvents.length, 1, "Should be one unenroll event"); + Assert.equal( + unenrollEvents[0].extra.experiment, + "foo", + "Experiment slug should match" + ); + Assert.equal( + unenrollEvents[0].extra.reason, + "l10n-missing-locale", + "Reason should match" + ); + + const validationFailedEvents = + Glean.nimbusEvents.validationFailed.testGetValue(); + Assert.equal( + validationFailedEvents.length, + 1, + "Should be one validation failed event" + ); + Assert.equal( + validationFailedEvents[0].extra.experiment, + "foo", + "Experiment slug should match" + ); + Assert.equal( + validationFailedEvents[0].extra.reason, + "l10n-missing-locale", + "Reason should match" + ); + Assert.equal( + validationFailedEvents[0].extra.locale, + "en-US", + "Locale should match" + ); + + TelemetryTestUtils.assertEvents( + [ + { + value: "foo", + extra: { + reason: "l10n-missing-locale", + }, + }, + ], + { + category: "normandy", + method: "unenroll", + object: "nimbus_experiment", + }, + { clear: false } + ); + + TelemetryTestUtils.assertEvents( + [ + { + value: "foo", + extra: { + reason: "l10n-missing-locale", + locale: "en-US", + }, + }, + ], + { + category: "normandy", + method: "validationFailed", + object: "nimbus_experiment", + } + ); + + await cleanupStore(manager.store); + sandbox.reset(); +}); |