"use strict"; const { PreferenceExperiments } = ChromeUtils.importESModule( "resource://normandy/lib/PreferenceExperiments.sys.mjs" ); const { CleanupManager } = ChromeUtils.importESModule( "resource://normandy/lib/CleanupManager.sys.mjs" ); const { NormandyUtils } = ChromeUtils.importESModule( "resource://normandy/lib/NormandyUtils.sys.mjs" ); const { NormandyTestUtils } = ChromeUtils.importESModule( "resource://testing-common/NormandyTestUtils.sys.mjs" ); // Save ourselves some typing const { withMockExperiments } = PreferenceExperiments; const DefaultPreferences = new Preferences({ defaultBranch: true }); const startupPrefs = "app.normandy.startupExperimentPrefs"; const { preferenceStudyFactory } = NormandyTestUtils.factories; const NOW = new Date(); const mockV1Data = { hypothetical_experiment: { name: "hypothetical_experiment", branch: "hypo_1", expired: false, lastSeen: NOW.toJSON(), preferenceName: "some.pref", preferenceValue: 2, preferenceType: "integer", previousPreferenceValue: 1, preferenceBranchType: "user", experimentType: "exp", }, another_experiment: { name: "another_experiment", branch: "another_4", expired: true, lastSeen: NOW.toJSON(), preferenceName: "another.pref", preferenceValue: true, preferenceType: "boolean", previousPreferenceValue: false, preferenceBranchType: "default", experimentType: "exp", }, }; const mockV2Data = { experiments: { hypothetical_experiment: { name: "hypothetical_experiment", branch: "hypo_1", expired: false, lastSeen: NOW.toJSON(), preferenceName: "some.pref", preferenceValue: 2, preferenceType: "integer", previousPreferenceValue: 1, preferenceBranchType: "user", experimentType: "exp", }, another_experiment: { name: "another_experiment", branch: "another_4", expired: true, lastSeen: NOW.toJSON(), preferenceName: "another.pref", preferenceValue: true, preferenceType: "boolean", previousPreferenceValue: false, preferenceBranchType: "default", experimentType: "exp", }, }, }; const mockV3Data = { experiments: { hypothetical_experiment: { name: "hypothetical_experiment", branch: "hypo_1", expired: false, lastSeen: NOW.toJSON(), preferences: { "some.pref": { preferenceValue: 2, preferenceType: "integer", previousPreferenceValue: 1, preferenceBranchType: "user", }, }, experimentType: "exp", }, another_experiment: { name: "another_experiment", branch: "another_4", expired: true, lastSeen: NOW.toJSON(), preferences: { "another.pref": { preferenceValue: true, preferenceType: "boolean", previousPreferenceValue: false, preferenceBranchType: "default", }, }, experimentType: "exp", }, }, }; const mockV4Data = { experiments: { hypothetical_experiment: { name: "hypothetical_experiment", branch: "hypo_1", actionName: "SinglePreferenceExperimentAction", expired: false, lastSeen: NOW.toJSON(), preferences: { "some.pref": { preferenceValue: 2, preferenceType: "integer", previousPreferenceValue: 1, preferenceBranchType: "user", }, }, experimentType: "exp", }, another_experiment: { name: "another_experiment", branch: "another_4", actionName: "SinglePreferenceExperimentAction", expired: true, lastSeen: NOW.toJSON(), preferences: { "another.pref": { preferenceValue: true, preferenceType: "boolean", previousPreferenceValue: false, preferenceBranchType: "default", }, }, experimentType: "exp", }, }, }; const mockV5Data = { experiments: { hypothetical_experiment: { slug: "hypothetical_experiment", branch: "hypo_1", actionName: "SinglePreferenceExperimentAction", expired: false, lastSeen: NOW.toJSON(), preferences: { "some.pref": { preferenceValue: 2, preferenceType: "integer", previousPreferenceValue: 1, preferenceBranchType: "user", }, }, experimentType: "exp", }, another_experiment: { slug: "another_experiment", branch: "another_4", actionName: "SinglePreferenceExperimentAction", expired: true, lastSeen: NOW.toJSON(), preferences: { "another.pref": { preferenceValue: true, preferenceType: "boolean", previousPreferenceValue: false, preferenceBranchType: "default", }, }, experimentType: "exp", }, }, }; const migrationsInfo = [ { migration: PreferenceExperiments.migrations.migration01MoveExperiments, dataBefore: mockV1Data, dataAfter: mockV2Data, }, { migration: PreferenceExperiments.migrations.migration02MultiPreference, dataBefore: mockV2Data, dataAfter: mockV3Data, }, { migration: PreferenceExperiments.migrations.migration03AddActionName, dataBefore: mockV3Data, dataAfter: mockV4Data, }, { migration: PreferenceExperiments.migrations.migration04RenameNameToSlug, dataBefore: mockV4Data, dataAfter: mockV5Data, }, // Migration 5 is not a simple data migration. This style of tests does not apply to it. ]; /** * Make a mock `JsonFile` object with a no-op `saveSoon` method and a deep copy * of the data passed. * @param {Object} data the data in the store */ function makeMockJsonFile(data = {}) { return { // Deep clone the data in case migrations mutate it. data: JSON.parse(JSON.stringify(data)), saveSoon: () => {}, }; } /** Test that each migration results in the expected data */ add_task(async function test_migrations() { for (const { migration, dataAfter, dataBefore } of migrationsInfo) { let mockJsonFile = makeMockJsonFile(dataBefore); await migration(mockJsonFile); Assert.deepEqual( mockJsonFile.data, dataAfter, `Migration ${migration.name} should result in the expected data` ); } }); add_task(async function migrations_are_idempotent() { for (const { migration, dataBefore } of migrationsInfo) { const mockJsonFileOnce = makeMockJsonFile(dataBefore); const mockJsonFileTwice = makeMockJsonFile(dataBefore); await migration(mockJsonFileOnce); await migration(mockJsonFileTwice); await migration(mockJsonFileTwice); Assert.deepEqual( mockJsonFileOnce.data, mockJsonFileTwice.data, "migrating data twice should be idempotent for " + migration.name ); } }); add_task(async function migration03KeepsActionName() { let mockData = JSON.parse(JSON.stringify(mockV3Data)); mockData.experiments.another_experiment.actionName = "SomeOldAction"; const mockJsonFile = makeMockJsonFile(mockData); // Output should be the same as mockV4Data, but preserving the action. const migratedData = JSON.parse(JSON.stringify(mockV4Data)); migratedData.experiments.another_experiment.actionName = "SomeOldAction"; await PreferenceExperiments.migrations.migration03AddActionName(mockJsonFile); Assert.deepEqual(mockJsonFile.data, migratedData); }); // Test that migration 5 works as expected decorate_task( withMockExperiments([ NormandyTestUtils.factories.preferenceStudyFactory({ actionName: "PreferenceExperimentAction", expired: false, }), NormandyTestUtils.factories.preferenceStudyFactory({ actionName: "SinglePreferenceExperimentAction", expired: false, }), ]), async function migration05Works({ prefExperiments: [expKeep, expExpire] }) { // pre check const activeSlugsBefore = (await PreferenceExperiments.getAllActive()).map( e => e.slug ); Assert.deepEqual( activeSlugsBefore, [expKeep.slug, expExpire.slug], "Both experiments should be present and active before the migration" ); // run the migration await PreferenceExperiments.migrations.migration05RemoveOldAction(); // verify behavior const activeSlugsAfter = (await PreferenceExperiments.getAllActive()).map( e => e.slug ); Assert.deepEqual( activeSlugsAfter, [expKeep.slug], "The single pref experiment should be ended by the migration" ); const allSlugsAfter = (await PreferenceExperiments.getAll()).map( e => e.slug ); Assert.deepEqual( allSlugsAfter, [expKeep.slug, expExpire.slug], "Both experiments should still exist after the migration" ); } ); // clearAllExperimentStorage decorate_task( withMockExperiments([preferenceStudyFactory({ slug: "test" })]), async function ({ prefExperiments }) { ok(await PreferenceExperiments.has("test"), "Mock experiment is detected."); await PreferenceExperiments.clearAllExperimentStorage(); ok( !(await PreferenceExperiments.has("test")), "clearAllExperimentStorage removed all stored experiments" ); } ); // start should throw if an experiment with the given name already exists decorate_task( withMockExperiments([preferenceStudyFactory({ slug: "test" })]), withSendEventSpy(), async function ({ sendEventSpy }) { await Assert.rejects( PreferenceExperiments.start({ slug: "test", actionName: "SomeAction", branch: "branch", preferences: { "fake.preference": { preferenceValue: "value", preferenceType: "string", preferenceBranchType: "default", }, }, }), /test.*already exists/, "start threw an error due to a conflicting experiment name" ); sendEventSpy.assertEvents([ ["enrollFailed", "preference_study", "test", { reason: "name-conflict" }], ]); } ); // start should throw if an experiment for any of the given // preferences are active decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "test", preferences: { "fake.preferenceinteger": {} }, }), ]), withSendEventSpy(), async function ({ sendEventSpy }) { await Assert.rejects( PreferenceExperiments.start({ slug: "different", actionName: "SomeAction", branch: "branch", preferences: { "fake.preference": { preferenceValue: "value", preferenceType: "string", preferenceBranchType: "default", }, "fake.preferenceinteger": { preferenceValue: 2, preferenceType: "integer", preferenceBranchType: "default", }, }, }), /another.*is currently active/i, "start threw an error due to an active experiment for the given preference" ); sendEventSpy.assertEvents([ [ "enrollFailed", "preference_study", "different", { reason: "pref-conflict" }, ], ]); } ); // start should throw if an invalid preferenceBranchType is given decorate_task( withMockExperiments(), withSendEventSpy(), async function ({ sendEventSpy }) { await Assert.rejects( PreferenceExperiments.start({ slug: "test", actionName: "SomeAction", branch: "branch", preferences: { "fake.preference": { preferenceValue: "value", preferenceType: "string", preferenceBranchType: "invalid", }, }, }), /invalid value for preferenceBranchType: invalid/i, "start threw an error due to an invalid preference branch type" ); sendEventSpy.assertEvents([ [ "enrollFailed", "preference_study", "test", { reason: "invalid-branch" }, ], ]); } ); // start should save experiment data, modify preferences, and register a // watcher. decorate_task( withMockExperiments(), withMockPreferences(), withStub(PreferenceExperiments, "startObserver"), withSendEventSpy(), async function testStart({ prefExperiments, mockPreferences, startObserverStub, sendEventSpy, }) { mockPreferences.set("fake.preference", "oldvalue", "default"); mockPreferences.set("fake.preference", "uservalue", "user"); mockPreferences.set("fake.preferenceinteger", 1, "default"); mockPreferences.set("fake.preferenceinteger", 101, "user"); const experiment = { slug: "test", actionName: "SomeAction", branch: "branch", preferences: { "fake.preference": { preferenceValue: "newvalue", preferenceBranchType: "default", preferenceType: "string", }, "fake.preferenceinteger": { preferenceValue: 2, preferenceBranchType: "default", preferenceType: "integer", }, }, }; await PreferenceExperiments.start(experiment); ok(await PreferenceExperiments.get("test"), "start saved the experiment"); ok( startObserverStub.calledWith("test", experiment.preferences), "start registered an observer" ); const expectedExperiment = { slug: "test", branch: "branch", expired: false, preferences: { "fake.preference": { preferenceValue: "newvalue", preferenceType: "string", previousPreferenceValue: "oldvalue", preferenceBranchType: "default", overridden: true, }, "fake.preferenceinteger": { preferenceValue: 2, preferenceType: "integer", previousPreferenceValue: 1, preferenceBranchType: "default", overridden: true, }, }, }; const experimentSubset = {}; const actualExperiment = await PreferenceExperiments.get("test"); Object.keys(expectedExperiment).forEach( key => (experimentSubset[key] = actualExperiment[key]) ); Assert.deepEqual( experimentSubset, expectedExperiment, "start saved the experiment" ); is( DefaultPreferences.get("fake.preference"), "newvalue", "start modified the default preference" ); is( Preferences.get("fake.preference"), "uservalue", "start did not modify the user preference" ); is( Preferences.get(`${startupPrefs}.fake.preference`), "newvalue", "start saved the experiment value to the startup prefs tree" ); is( DefaultPreferences.get("fake.preferenceinteger"), 2, "start modified the default preference" ); is( Preferences.get("fake.preferenceinteger"), 101, "start did not modify the user preference" ); is( Preferences.get(`${startupPrefs}.fake.preferenceinteger`), 2, "start saved the experiment value to the startup prefs tree" ); } ); // start should modify the user preference for the user branch type decorate_task( withMockExperiments(), withMockPreferences(), withStub(PreferenceExperiments, "startObserver"), async function ({ mockPreferences, startObserverStub }) { mockPreferences.set("fake.preference", "olddefaultvalue", "default"); mockPreferences.set("fake.preference", "oldvalue", "user"); const experiment = { slug: "test", actionName: "SomeAction", branch: "branch", preferences: { "fake.preference": { preferenceValue: "newvalue", preferenceType: "string", preferenceBranchType: "user", }, }, }; await PreferenceExperiments.start(experiment); ok( startObserverStub.calledWith("test", experiment.preferences), "start registered an observer" ); const expectedExperiment = { slug: "test", branch: "branch", expired: false, preferences: { "fake.preference": { preferenceValue: "newvalue", preferenceType: "string", previousPreferenceValue: "oldvalue", preferenceBranchType: "user", }, }, }; const experimentSubset = {}; const actualExperiment = await PreferenceExperiments.get("test"); Object.keys(expectedExperiment).forEach( key => (experimentSubset[key] = actualExperiment[key]) ); Assert.deepEqual( experimentSubset, expectedExperiment, "start saved the experiment" ); Assert.notEqual( DefaultPreferences.get("fake.preference"), "newvalue", "start did not modify the default preference" ); is( Preferences.get("fake.preference"), "newvalue", "start modified the user preference" ); } ); // start should detect if a new preference value type matches the previous value type decorate_task( withMockPreferences(), withSendEventSpy(), async function ({ mockPreferences, sendEventSpy }) { mockPreferences.set("fake.type_preference", "oldvalue"); await Assert.rejects( PreferenceExperiments.start({ slug: "test", actionName: "SomeAction", branch: "branch", preferences: { "fake.type_preference": { preferenceBranchType: "user", preferenceValue: 12345, preferenceType: "integer", }, }, }), /previous preference value is of type/i, "start threw error for incompatible preference type" ); sendEventSpy.assertEvents([ ["enrollFailed", "preference_study", "test", { reason: "invalid-type" }], ]); } ); // startObserver should throw if an observer for the experiment is already // active. decorate_task(withMockExperiments(), async function () { PreferenceExperiments.startObserver("test", { "fake.preference": { preferenceType: "string", preferenceValue: "newvalue", }, }); Assert.throws( () => PreferenceExperiments.startObserver("test", { "another.fake": { preferenceType: "string", preferenceValue: "othervalue", }, }), /observer.*is already active/i, "startObservers threw due to a conflicting active observer" ); PreferenceExperiments.stopAllObservers(); }); // startObserver should register an observer that sends an event when preference // changes from its experimental value. decorate_task( withMockExperiments(), withMockPreferences(), withStub(PreferenceExperiments, "recordPrefChange"), async function testObserversCanObserveChanges({ mockPreferences, recordPrefChangeStub, }) { const preferences = { "fake.preferencestring": { preferenceType: "string", previousPreferenceValue: "startvalue", preferenceValue: "experimentvalue", }, // "newvalue", "fake.preferenceboolean": { preferenceType: "boolean", previousPreferenceValue: false, preferenceValue: true, }, // false "fake.preferenceinteger": { preferenceType: "integer", previousPreferenceValue: 1, preferenceValue: 2, }, // 42 }; const newValues = { "fake.preferencestring": "newvalue", "fake.preferenceboolean": false, "fake.preferenceinteger": 42, }; for (const [testPref, newValue] of Object.entries(newValues)) { const experimentSlug = "test-" + testPref; for (const [prefName, prefInfo] of Object.entries(preferences)) { mockPreferences.set(prefName, prefInfo.previousPreferenceValue); } // NOTE: startObserver does not modify the pref PreferenceExperiments.startObserver(experimentSlug, preferences); // Setting it to the experimental value should not trigger the call. for (const [prefName, prefInfo] of Object.entries(preferences)) { mockPreferences.set(prefName, prefInfo.preferenceValue); ok( !recordPrefChangeStub.called, "Changing to the experimental pref value did not trigger the observer" ); } // Setting it to something different should trigger the call. mockPreferences.set(testPref, newValue); Assert.deepEqual( recordPrefChangeStub.args, [[{ experimentSlug, preferenceName: testPref, reason: "observer" }]], "Changing to a different value triggered the observer" ); PreferenceExperiments.stopAllObservers(); recordPrefChangeStub.resetHistory(); } } ); // Changes to prefs that have an experimental pref as a prefix should not trigger the observer. decorate_task( withMockExperiments(), withMockPreferences(), withStub(PreferenceExperiments, "recordPrefChange"), async function testObserversCanObserveChanges({ mockPreferences, recordPrefChangeStub, }) { const preferences = { "fake.preference": { preferenceType: "string", previousPreferenceValue: "startvalue", preferenceValue: "experimentvalue", }, }; const experimentSlug = "test-prefix"; for (const [prefName, prefInfo] of Object.entries(preferences)) { mockPreferences.set(prefName, prefInfo.preferenceValue); } PreferenceExperiments.startObserver(experimentSlug, preferences); // Changing a preference that has the experimental pref as a prefix should // not trigger the observer. mockPreferences.set("fake.preference.extra", "value"); // Setting it to the experimental value should not trigger the call. ok( !recordPrefChangeStub.called, "Changing to the experimental pref value did not trigger the observer" ); PreferenceExperiments.stopAllObservers(); } ); decorate_task(withMockExperiments(), async function testHasObserver() { PreferenceExperiments.startObserver("test", { "fake.preference": { preferenceType: "string", preferenceValue: "experimentValue", }, }); ok( await PreferenceExperiments.hasObserver("test"), "hasObserver should detect active observers" ); ok( !(await PreferenceExperiments.hasObserver("missing")), "hasObserver shouldn't detect inactive observers" ); PreferenceExperiments.stopAllObservers(); }); // stopObserver should throw if there is no observer active for it to stop. decorate_task(withMockExperiments(), async function () { Assert.throws( () => PreferenceExperiments.stopObserver("neveractive"), /no observer.*found/i, "stopObserver threw because there was not matching active observer" ); }); // stopObserver should cancel an active observers. decorate_task( withMockExperiments(), withMockPreferences(), withStub(PreferenceExperiments, "stop", { returnValue: Promise.resolve() }), async function ({ mockPreferences, stopStub }) { const preferenceInfo = { "fake.preferencestring": { preferenceType: "string", preferenceValue: "experimentvalue", }, "fake.preferenceinteger": { preferenceType: "integer", preferenceValue: 2, }, }; mockPreferences.set("fake.preference", "startvalue"); PreferenceExperiments.startObserver("test", preferenceInfo); PreferenceExperiments.stopObserver("test"); // Setting the preference now that the observer is stopped should not call // stop. mockPreferences.set("fake.preferencestring", "newvalue"); ok( !stopStub.called, "stopObserver successfully removed the observer for string" ); mockPreferences.set("fake.preferenceinteger", 42); ok( !stopStub.called, "stopObserver successfully removed the observer for integer" ); // Now that the observer is stopped, start should be able to start a new one // without throwing. try { PreferenceExperiments.startObserver("test", preferenceInfo); } catch (err) { ok( false, "startObserver did not throw an error for an observer that was already stopped" ); } PreferenceExperiments.stopAllObservers(); } ); // stopAllObservers decorate_task( withMockExperiments(), withMockPreferences(), withStub(PreferenceExperiments, "stop", { returnValue: Promise.resolve() }), async function ({ mockPreferences, stopStub }) { mockPreferences.set("fake.preference", "startvalue"); mockPreferences.set("other.fake.preference", "startvalue"); PreferenceExperiments.startObserver("test", { "fake.preference": { preferenceType: "string", preferenceValue: "experimentvalue", }, }); PreferenceExperiments.startObserver("test2", { "other.fake.preference": { preferenceType: "string", preferenceValue: "experimentvalue", }, }); PreferenceExperiments.stopAllObservers(); // Setting the preference now that the observers are stopped should not call // stop. mockPreferences.set("fake.preference", "newvalue"); mockPreferences.set("other.fake.preference", "newvalue"); ok(!stopStub.called, "stopAllObservers successfully removed all observers"); // Now that the observers are stopped, start should be able to start new // observers without throwing. try { PreferenceExperiments.startObserver("test", { "fake.preference": { preferenceType: "string", preferenceValue: "experimentvalue", }, }); PreferenceExperiments.startObserver("test2", { "other.fake.preference": { preferenceType: "string", preferenceValue: "experimentvalue", }, }); } catch (err) { ok( false, "startObserver did not throw an error for an observer that was already stopped" ); } PreferenceExperiments.stopAllObservers(); } ); // markLastSeen should throw if it can't find a matching experiment decorate_task(withMockExperiments(), async function () { await Assert.rejects( PreferenceExperiments.markLastSeen("neveractive"), /could not find/i, "markLastSeen threw because there was not a matching experiment" ); }); // markLastSeen should update the lastSeen date const oldDate = new Date(1988, 10, 1).toJSON(); decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "test", lastSeen: oldDate }), ]), async function ({ prefExperiments: [experiment] }) { await PreferenceExperiments.markLastSeen("test"); Assert.notEqual( experiment.lastSeen, oldDate, "markLastSeen updated the experiment lastSeen date" ); } ); // stop should throw if an experiment with the given name doesn't exist decorate_task( withMockExperiments(), withSendEventSpy(), async function ({ sendEventSpy }) { await Assert.rejects( PreferenceExperiments.stop("test"), /could not find/i, "stop threw an error because there are no experiments with the given name" ); sendEventSpy.assertEvents([ [ "unenrollFailed", "preference_study", "test", { reason: "does-not-exist" }, ], ]); } ); // stop should throw if the experiment is already expired decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "test", expired: true }), ]), withSendEventSpy(), async function ({ sendEventSpy }) { await Assert.rejects( PreferenceExperiments.stop("test"), /already expired/, "stop threw an error because the experiment was already expired" ); sendEventSpy.assertEvents([ [ "unenrollFailed", "preference_study", "test", { reason: "already-unenrolled" }, ], ]); } ); // stop should mark the experiment as expired, stop its observer, and revert the // preference value. decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "test", expired: false, branch: "fakebranch", preferences: { "fake.preference": { preferenceValue: "experimentvalue", preferenceType: "string", previousPreferenceValue: "oldvalue", preferenceBranchType: "default", }, }, }), ]), withMockPreferences(), withSpy(PreferenceExperiments, "stopObserver"), withSendEventSpy(), async function testStop({ mockPreferences, stopObserverSpy, sendEventSpy }) { // this assertion is mostly useful for --verify test runs, to make // sure that tests clean up correctly. ok(!Preferences.get("fake.preference"), "preference should start unset"); mockPreferences.set( `${startupPrefs}.fake.preference`, "experimentvalue", "user" ); mockPreferences.set("fake.preference", "experimentvalue", "default"); PreferenceExperiments.startObserver("test", { "fake.preference": { preferenceType: "string", preferenceValue: "experimentvalue", }, }); await PreferenceExperiments.stop("test", { reason: "test-reason" }); ok(stopObserverSpy.calledWith("test"), "stop removed an observer"); const experiment = await PreferenceExperiments.get("test"); is(experiment.expired, true, "stop marked the experiment as expired"); is( DefaultPreferences.get("fake.preference"), "oldvalue", "stop reverted the preference to its previous value" ); ok( !Services.prefs.prefHasUserValue(`${startupPrefs}.fake.preference`), "stop cleared the startup preference for fake.preference." ); sendEventSpy.assertEvents([ [ "unenroll", "preference_study", "test", { didResetValue: "true", reason: "test-reason", branch: "fakebranch", }, ], ]); PreferenceExperiments.stopAllObservers(); } ); // stop should also support user pref experiments decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "test", expired: false, preferences: { "fake.preference": { preferenceValue: "experimentvalue", preferenceType: "string", previousPreferenceValue: "oldvalue", preferenceBranchType: "user", }, }, }), ]), withMockPreferences(), withStub(PreferenceExperiments, "stopObserver"), withStub(PreferenceExperiments, "hasObserver"), async function testStopUserPrefs({ mockPreferences, stopObserverStub, hasObserverStub, }) { hasObserverStub.returns(true); mockPreferences.set("fake.preference", "experimentvalue", "user"); PreferenceExperiments.startObserver("test", { "fake.preference": { preferenceType: "string", preferenceValue: "experimentvalue", }, }); await PreferenceExperiments.stop("test"); ok(stopObserverStub.calledWith("test"), "stop removed an observer"); const experiment = await PreferenceExperiments.get("test"); is(experiment.expired, true, "stop marked the experiment as expired"); is( Preferences.get("fake.preference"), "oldvalue", "stop reverted the preference to its previous value" ); stopObserverStub.restore(); PreferenceExperiments.stopAllObservers(); } ); // stop should remove a preference that had no value prior to an experiment for user prefs decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "test", expired: false, preferences: { "fake.preference": { preferenceValue: "experimentvalue", preferenceType: "string", previousPreferenceValue: null, preferenceBranchType: "user", }, }, }), ]), withMockPreferences(), withStub(PreferenceExperiments, "stopObserver"), async function ({ mockPreferences }) { mockPreferences.set("fake.preference", "experimentvalue", "user"); await PreferenceExperiments.stop("test"); ok( !Preferences.isSet("fake.preference"), "stop removed the preference that had no value prior to the experiment" ); } ); // stop should not modify a preference if resetValue is false decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "test", expired: false, branch: "fakebranch", preferences: { "fake.preference": { preferenceValue: "experimentvalue", preferenceType: "string", previousPreferenceValue: "oldvalue", preferenceBranchType: "default", }, }, }), ]), withMockPreferences(), withStub(PreferenceExperiments, "stopObserver"), withSendEventSpy(), async function testStopReset({ mockPreferences, sendEventSpy }) { mockPreferences.set("fake.preference", "customvalue", "default"); await PreferenceExperiments.stop("test", { reason: "test-reason", resetValue: false, }); is( DefaultPreferences.get("fake.preference"), "customvalue", "stop did not modify the preference" ); sendEventSpy.assertEvents([ [ "unenroll", "preference_study", "test", { didResetValue: "false", reason: "test-reason", branch: "fakebranch", }, ], ]); } ); // stop should include the system that stopped it decorate_task( withMockExperiments([preferenceStudyFactory({ expired: true })]), withSendEventSpy, async function testStopUserPrefs([experiment], sendEventSpy) { await Assert.rejects( PreferenceExperiments.stop(experiment.slug, { caller: "testCaller", reason: "original-reason", }), /.*already expired.*/, "Stopped an expired experiment should throw an exception" ); const expectedExtra = { reason: "already-unenrolled", enrollmentId: experiment.enrollmentId, originalReason: "original-reason", }; if (AppConstants.NIGHTLY_BUILD) { expectedExtra.caller = "testCaller"; } sendEventSpy.assertEvents([ ["unenrollFailed", "preference_study", experiment.slug, expectedExtra], ]); } ); // get should throw if no experiment exists with the given name decorate_task(withMockExperiments(), async function () { await Assert.rejects( PreferenceExperiments.get("neverexisted"), /could not find/i, "get rejects if no experiment with the given name is found" ); }); // get decorate_task( withMockExperiments([preferenceStudyFactory({ slug: "test" })]), async function ({ prefExperiments }) { const experiment = await PreferenceExperiments.get("test"); is(experiment.slug, "test", "get fetches the correct experiment"); // Modifying the fetched experiment must not edit the data source. experiment.slug = "othername"; const refetched = await PreferenceExperiments.get("test"); is(refetched.slug, "test", "get returns a copy of the experiment"); } ); // get all decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "experiment1", disabled: false }), preferenceStudyFactory({ slug: "experiment2", disabled: true }), ]), async function testGetAll({ prefExperiments: [experiment1, experiment2] }) { const fetchedExperiments = await PreferenceExperiments.getAll(); is( fetchedExperiments.length, 2, "getAll returns a list of all stored experiments" ); Assert.deepEqual( fetchedExperiments.find(e => e.slug === "experiment1"), experiment1, "getAll returns a list with the correct experiments" ); const fetchedExperiment2 = fetchedExperiments.find( e => e.slug === "experiment2" ); Assert.deepEqual( fetchedExperiment2, experiment2, "getAll returns a list with the correct experiments, including disabled ones" ); fetchedExperiment2.slug = "otherslug"; is( experiment2.slug, "experiment2", "getAll returns copies of the experiments" ); } ); // get all active decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "active", expired: false, }), preferenceStudyFactory({ slug: "inactive", expired: true, }), ]), withMockPreferences(), async function testGetAllActive({ prefExperiments: [activeExperiment, inactiveExperiment], }) { let allActiveExperiments = await PreferenceExperiments.getAllActive(); Assert.deepEqual( allActiveExperiments, [activeExperiment], "getAllActive only returns active experiments" ); allActiveExperiments[0].slug = "newfakename"; allActiveExperiments = await PreferenceExperiments.getAllActive(); Assert.notEqual( allActiveExperiments, "newfakename", "getAllActive returns copies of stored experiments" ); } ); // has decorate_task( withMockExperiments([preferenceStudyFactory({ slug: "test" })]), async function () { ok( await PreferenceExperiments.has("test"), "has returned true for a stored experiment" ); ok( !(await PreferenceExperiments.has("missing")), "has returned false for a missing experiment" ); } ); // init should register telemetry experiments decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "test", branch: "branch", preferences: { "fake.pref": { preferenceValue: "experiment value", preferenceBranchType: "default", preferenceType: "string", }, }, }), ]), withMockPreferences(), withStub(TelemetryEnvironment, "setExperimentActive"), withStub(PreferenceExperiments, "startObserver"), async function testInit({ prefExperiments, mockPreferences, setExperimentActiveStub, }) { mockPreferences.set("fake.pref", "experiment value"); await PreferenceExperiments.init(); ok( setExperimentActiveStub.calledWith("test", "branch", { type: "normandy-exp", enrollmentId: prefExperiments[0].enrollmentId, }), "Experiment is registered by init" ); } ); // init should use the provided experiment type decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "test", branch: "branch", preferences: { "fake.pref": { preferenceValue: "experiment value", preferenceType: "string", }, }, experimentType: "pref-test", }), ]), withMockPreferences(), withStub(TelemetryEnvironment, "setExperimentActive"), withStub(PreferenceExperiments, "startObserver"), async function testInit({ mockPreferences, setExperimentActiveStub }) { mockPreferences.set("fake.pref", "experiment value"); await PreferenceExperiments.init(); ok( setExperimentActiveStub.calledWith("test", "branch", { type: "normandy-pref-test", enrollmentId: sinon.match(NormandyTestUtils.isUuid), }), "init should use the provided experiment type" ); } ); // starting and stopping experiments should register in telemetry decorate_task( withMockExperiments(), withStub(TelemetryEnvironment, "setExperimentActive"), withStub(TelemetryEnvironment, "setExperimentInactive"), withSendEventSpy(), async function testStartAndStopTelemetry({ setExperimentActiveStub, setExperimentInactiveStub, sendEventSpy, }) { let { enrollmentId } = await PreferenceExperiments.start({ slug: "test", actionName: "SomeAction", branch: "branch", preferences: { "fake.preference": { preferenceValue: "value", preferenceType: "string", preferenceBranchType: "default", }, }, }); ok( NormandyTestUtils.isUuid(enrollmentId), "Experiment should have a UUID enrollmentId" ); Assert.deepEqual( setExperimentActiveStub.getCall(0).args, ["test", "branch", { type: "normandy-exp", enrollmentId }], "Experiment is registered by start()" ); await PreferenceExperiments.stop("test", { reason: "test-reason" }); Assert.deepEqual( setExperimentInactiveStub.args, [["test"]], "Experiment is unregistered by stop()" ); sendEventSpy.assertEvents([ [ "enroll", "preference_study", "test", { experimentType: "exp", branch: "branch", enrollmentId, }, ], [ "unenroll", "preference_study", "test", { reason: "test-reason", didResetValue: "true", branch: "branch", enrollmentId, }, ], ]); } ); // starting experiments should use the provided experiment type decorate_task( withMockExperiments(), withStub(TelemetryEnvironment, "setExperimentActive"), withStub(TelemetryEnvironment, "setExperimentInactive"), withSendEventSpy(), async function testInitTelemetryExperimentType({ setExperimentActiveStub, sendEventSpy, }) { const { enrollmentId } = await PreferenceExperiments.start({ slug: "test", actionName: "SomeAction", branch: "branch", preferences: { "fake.preference": { preferenceValue: "value", preferenceType: "string", preferenceBranchType: "default", }, }, experimentType: "pref-test", }); Assert.deepEqual( setExperimentActiveStub.getCall(0).args, ["test", "branch", { type: "normandy-pref-test", enrollmentId }], "start() should register the experiment with the provided type" ); sendEventSpy.assertEvents([ [ "enroll", "preference_study", "test", { experimentType: "pref-test", branch: "branch", enrollmentId, }, ], ]); // start sets the passed preference in a way that is hard to mock. // Reset the preference so it doesn't interfere with other tests. Services.prefs.getDefaultBranch("fake.preference").deleteBranch(""); } ); // When a default-branch experiment starts, and some preferences already have // user set values, they should immediately send telemetry events. decorate_task( withMockExperiments(), withStub(TelemetryEnvironment, "setExperimentActive"), withStub(TelemetryEnvironment, "setExperimentInactive"), withSendEventSpy(), withMockPreferences(), async function testOverriddenAtEnroll({ sendEventSpy, mockPreferences }) { // consts for preference names to avoid typos const prefNames = { defaultNoOverride: "fake.preference.default-no-override", defaultWithOverride: "fake.preference.default-with-override", userNoOverride: "fake.preference.user-no-override", userWithOverride: "fake.preference.user-with-override", }; // Set up preferences for the test. Two preferences with only default // values, and two preferences with both default and user values. mockPreferences.set( prefNames.defaultNoOverride, "default value", "default" ); mockPreferences.set( prefNames.defaultWithOverride, "default value", "default" ); mockPreferences.set(prefNames.defaultWithOverride, "user value", "user"); mockPreferences.set(prefNames.userNoOverride, "default value", "default"); mockPreferences.set(prefNames.userWithOverride, "default value", "default"); mockPreferences.set(prefNames.userWithOverride, "user value", "user"); // Start the experiment with two each of default-branch and user-branch // methods, one each of which will already be overridden. const { enrollmentId, slug } = await PreferenceExperiments.start({ slug: "test-experiment", actionName: "someAction", branch: "experimental-branch", preferences: { [prefNames.defaultNoOverride]: { preferenceValue: "experimental value", preferenceType: "string", preferenceBranchType: "default", }, [prefNames.defaultWithOverride]: { preferenceValue: "experimental value", preferenceType: "string", preferenceBranchType: "default", }, [prefNames.userNoOverride]: { preferenceValue: "experimental value", preferenceType: "string", preferenceBranchType: "user", }, [prefNames.userWithOverride]: { preferenceValue: "experimental value", preferenceType: "string", preferenceBranchType: "user", }, }, experimentType: "pref-test", }); sendEventSpy.assertEvents([ [ "enroll", "preference_study", slug, { experimentType: "pref-test", branch: "experimental-branch", enrollmentId, }, ], [ "expPrefChanged", "preference_study", slug, { preferenceName: prefNames.defaultWithOverride, reason: "onEnroll", enrollmentId, }, ], ]); } ); // Experiments shouldn't be recorded by init() in telemetry if they are expired decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "expired", branch: "branch", expired: true, }), ]), withStub(TelemetryEnvironment, "setExperimentActive"), async function testInitTelemetryExpired({ setExperimentActiveStub }) { await PreferenceExperiments.init(); ok( !setExperimentActiveStub.called, "Expired experiment is not registered by init" ); } ); // Experiments should record if the preference has been changed when init() is // called and no previous override had been observed. decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "test", preferences: { "fake.preference.1": { preferenceValue: "experiment value 1", preferenceType: "string", overridden: false, }, "fake.preference.2": { preferenceValue: "experiment value 2", preferenceType: "string", overridden: true, }, }, }), ]), withMockPreferences(), withStub(PreferenceExperiments, "recordPrefChange"), async function testInitChanges({ mockPreferences, recordPrefChangeStub, prefExperiments: [experiment], }) { mockPreferences.set("fake.preference.1", "experiment value 1", "default"); mockPreferences.set("fake.preference.1", "changed value 1", "user"); mockPreferences.set("fake.preference.2", "experiment value 2", "default"); mockPreferences.set("fake.preference.2", "changed value 2", "user"); await PreferenceExperiments.init(); is( Preferences.get("fake.preference.1"), "changed value 1", "Preference value was not changed" ); is( Preferences.get("fake.preference.2"), "changed value 2", "Preference value was not changed" ); Assert.deepEqual( recordPrefChangeStub.args, [ [ { experiment, preferenceName: "fake.preference.1", reason: "sideload", }, ], ], "Only one experiment preference change should be recorded" ); } ); // init should register an observer for experiments decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "test", preferences: { "fake.preference": { preferenceValue: "experiment value", preferenceType: "string", previousPreferenceValue: "oldfakevalue", }, }, }), ]), withMockPreferences(), withStub(PreferenceExperiments, "startObserver"), withStub(PreferenceExperiments, "stop"), withStub(CleanupManager, "addCleanupHandler"), async function testInitRegistersObserver({ mockPreferences, startObserverStub, stopStub, }) { stopStub.throws("Stop should not be called"); mockPreferences.set("fake.preference", "experiment value", "default"); is( Preferences.get("fake.preference"), "experiment value", "pref shouldn't have a user value" ); await PreferenceExperiments.init(); ok(startObserverStub.calledOnce, "init should register an observer"); Assert.deepEqual( startObserverStub.getCall(0).args, [ "test", { "fake.preference": { preferenceType: "string", preferenceValue: "experiment value", previousPreferenceValue: "oldfakevalue", preferenceBranchType: "default", overridden: false, }, }, ], "init should register an observer with the right args" ); } ); // saveStartupPrefs decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "char", preferences: { "fake.char": { preferenceValue: "string", preferenceType: "string", }, }, }), preferenceStudyFactory({ slug: "int", preferences: { "fake.int": { preferenceValue: 2, preferenceType: "int", }, }, }), preferenceStudyFactory({ slug: "bool", preferences: { "fake.bool": { preferenceValue: true, preferenceType: "boolean", }, }, }), ]), async function testSaveStartupPrefs() { Services.prefs.deleteBranch(startupPrefs); Services.prefs.setBoolPref(`${startupPrefs}.fake.old`, true); await PreferenceExperiments.saveStartupPrefs(); ok( Services.prefs.getBoolPref(`${startupPrefs}.fake.bool`), "The startup value for fake.bool was saved." ); is( Services.prefs.getCharPref(`${startupPrefs}.fake.char`), "string", "The startup value for fake.char was saved." ); is( Services.prefs.getIntPref(`${startupPrefs}.fake.int`), 2, "The startup value for fake.int was saved." ); ok( !Services.prefs.prefHasUserValue(`${startupPrefs}.fake.old`), "saveStartupPrefs deleted old startup pref values." ); } ); // saveStartupPrefs errors for invalid pref type decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "test", preferences: { "fake.invalidValue": { preferenceValue: new Date(), }, }, }), ]), async function testSaveStartupPrefsError() { await Assert.rejects( PreferenceExperiments.saveStartupPrefs(), /invalid preference type/i, "saveStartupPrefs throws if an experiment has an invalid preference value type" ); } ); // saveStartupPrefs should not store values for user-branch recipes decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "defaultBranchRecipe", preferences: { "fake.default": { preferenceValue: "experiment value", preferenceType: "string", preferenceBranchType: "default", }, }, }), preferenceStudyFactory({ slug: "userBranchRecipe", preferences: { "fake.user": { preferenceValue: "experiment value", preferenceType: "string", preferenceBranchType: "user", }, }, }), ]), async function testSaveStartupPrefsUserBranch() { Assert.deepEqual( Services.prefs.getChildList(startupPrefs), [], "As a prerequisite no startup prefs are set" ); await PreferenceExperiments.saveStartupPrefs(); Assert.deepEqual( Services.prefs.getChildList(startupPrefs), [`${startupPrefs}.fake.default`], "only the expected prefs are set" ); is( Services.prefs.getCharPref( `${startupPrefs}.fake.default`, "fallback value" ), "experiment value", "The startup value for fake.default was set" ); is( Services.prefs.getPrefType(`${startupPrefs}.fake.user`), Services.prefs.PREF_INVALID, "The startup value for fake.user was not set" ); Services.prefs.deleteBranch(startupPrefs); } ); // test that default branch prefs restore to the right value if the default pref changes decorate_task( withMockExperiments(), withMockPreferences(), withStub(PreferenceExperiments, "startObserver"), withStub(PreferenceExperiments, "stopObserver"), async function testDefaultBranchStop({ mockPreferences }) { const prefName = "fake.preference"; mockPreferences.set(prefName, "old version's value", "default"); // start an experiment await PreferenceExperiments.start({ slug: "test", actionName: "SomeAction", branch: "branch", preferences: { [prefName]: { preferenceValue: "experiment value", preferenceBranchType: "default", preferenceType: "string", }, }, }); is( Services.prefs.getCharPref(prefName), "experiment value", "Starting an experiment should change the pref" ); // Now pretend that firefox has updated and restarted to a version // where the built-default value of fake.preference is something // else. Bootstrap has run and changed the pref to the // experimental value, and produced the call to // recordOriginalValues below. PreferenceExperiments.recordOriginalValues({ [prefName]: "new version's value", }); is( Services.prefs.getCharPref(prefName), "experiment value", "Recording original values shouldn't affect the preference." ); // Now stop the experiment. It should revert to the new version's default, not the old. await PreferenceExperiments.stop("test"); is( Services.prefs.getCharPref(prefName), "new version's value", "Preference should revert to new default" ); } ); // test that default branch prefs restore to the right value if the preference is removed decorate_task( withMockExperiments(), withMockPreferences(), withStub(PreferenceExperiments, "startObserver"), withStub(PreferenceExperiments, "stopObserver"), async function testDefaultBranchStop({ mockPreferences }) { const prefName = "fake.preference"; mockPreferences.set(prefName, "old version's value", "default"); // start an experiment await PreferenceExperiments.start({ slug: "test", actionName: "SomeAction", branch: "branch", preferences: { [prefName]: { preferenceValue: "experiment value", preferenceBranchType: "default", preferenceType: "string", }, }, }); is( Services.prefs.getCharPref(prefName), "experiment value", "Starting an experiment should change the pref" ); // Now pretend that firefox has updated and restarted to a version // where fake.preference has been removed in the default pref set. // Bootstrap has run and changed the pref to the experimental // value, and produced the call to recordOriginalValues below. PreferenceExperiments.recordOriginalValues({ [prefName]: null }); is( Services.prefs.getCharPref(prefName), "experiment value", "Recording original values shouldn't affect the preference." ); // Now stop the experiment. It should remove the preference await PreferenceExperiments.stop("test"); is( Services.prefs.getCharPref(prefName, "DEFAULT"), "DEFAULT", "Preference should be absent" ); } ).skip(/* bug 1502410 and bug 1505941 */); // stop should pass "unknown" to telemetry event for `reason` if none is specified decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "test", preferences: { "fake.preference": { preferenceValue: "experiment value", preferenceType: "string", }, }, }), ]), withMockPreferences(), withStub(PreferenceExperiments, "stopObserver"), withSendEventSpy(), async function testStopUnknownReason({ mockPreferences, sendEventSpy }) { mockPreferences.set("fake.preference", "default value", "default"); await PreferenceExperiments.stop("test"); is( sendEventSpy.getCall(0).args[3].reason, "unknown", "PreferenceExperiments.stop() should use unknown as the default reason" ); } ); // stop should pass along the value for resetValue to Telemetry Events as didResetValue decorate_task( withMockExperiments([ preferenceStudyFactory({ slug: "test1", preferences: { "fake.preference1": { preferenceValue: "experiment value", preferenceType: "string", previousValue: "previous", }, }, }), preferenceStudyFactory({ slug: "test2", preferences: { "fake.preference2": { preferenceValue: "experiment value", preferenceType: "string", previousValue: "previous", }, }, }), ]), withMockPreferences(), withStub(PreferenceExperiments, "stopObserver"), withSendEventSpy(), async function testStopResetValue({ mockPreferences, sendEventSpy }) { mockPreferences.set("fake.preference1", "default value", "default"); await PreferenceExperiments.stop("test1", { resetValue: true }); is(sendEventSpy.callCount, 1); is( sendEventSpy.getCall(0).args[3].didResetValue, "true", "PreferenceExperiments.stop() should pass true values of resetValue as didResetValue" ); mockPreferences.set("fake.preference2", "default value", "default"); await PreferenceExperiments.stop("test2", { resetValue: false }); is(sendEventSpy.callCount, 2); is( sendEventSpy.getCall(1).args[3].didResetValue, "false", "PreferenceExperiments.stop() should pass false values of resetValue as didResetValue" ); } ); // `recordPrefChange` should send the right telemetry and mark the pref as // overridden when passed an experiment decorate_task( withMockExperiments([ preferenceStudyFactory({ preferences: { "test.pref": {}, }, }), ]), withSendEventSpy(), async function testRecordPrefChangeWorks({ sendEventSpy, prefExperiments: [experiment], }) { is( experiment.preferences["test.pref"].overridden, false, "Precondition: the pref should not be overridden yet" ); await PreferenceExperiments.recordPrefChange({ experiment, preferenceName: "test.pref", reason: "test-run", }); experiment = await PreferenceExperiments.get(experiment.slug); is( experiment.preferences["test.pref"].overridden, true, "The pref should be marked as overridden" ); sendEventSpy.assertEvents([ [ "expPrefChanged", "preference_study", experiment.slug, { preferenceName: "test.pref", reason: "test-run", enrollmentId: experiment.enrollmentId, }, ], ]); } ); // `recordPrefChange` should send the right telemetry and mark the pref as // overridden when passed a slug decorate_task( withMockExperiments([ preferenceStudyFactory({ preferences: { "test.pref": {}, }, }), ]), withSendEventSpy(), async function testRecordPrefChangeWorks({ sendEventSpy, prefExperiments: [experiment], }) { is( experiment.preferences["test.pref"].overridden, false, "Precondition: the pref should not be overridden yet" ); await PreferenceExperiments.recordPrefChange({ experimentSlug: experiment.slug, preferenceName: "test.pref", reason: "test-run", }); experiment = await PreferenceExperiments.get(experiment.slug); is( experiment.preferences["test.pref"].overridden, true, "The pref should be marked as overridden" ); sendEventSpy.assertEvents([ [ "expPrefChanged", "preference_study", experiment.slug, { preferenceName: "test.pref", reason: "test-run", enrollmentId: experiment.enrollmentId, }, ], ]); } ); // When a default-branch experiment starts, prefs that already have user values // should not be changed. decorate_task( withMockExperiments(), withStub(TelemetryEnvironment, "setExperimentActive"), withStub(TelemetryEnvironment, "setExperimentInactive"), withSendEventSpy(), withMockPreferences(), async function testOverriddenAtEnrollNoChange({ mockPreferences }) { // Set up a situation where the user has changed the value of the pref away // from the default. Then run a default experiment that changes the // preference to the same value. mockPreferences.set("test.pref", "old value", "default"); mockPreferences.set("test.pref", "new value", "user"); await PreferenceExperiments.start({ slug: "test-experiment", actionName: "someAction", branch: "experimental-branch", preferences: { "test.pref": { preferenceValue: "new value", preferenceType: "string", preferenceBranchType: "default", }, }, experimentType: "pref-test", }); is( Services.prefs.getCharPref("test.pref"), "new value", "User value should be preserved" ); is( Services.prefs.getDefaultBranch("").getCharPref("test.pref"), "old value", "Default value should not have changed" ); const experiment = await PreferenceExperiments.get("test-experiment"); ok( experiment.preferences["test.pref"].overridden, "Pref should be marked as overridden" ); } ); // When a default-branch experiment starts, prefs that already exist and that // have user values should not be changed. // Bug 1735344: // eslint-disable-next-line mozilla/reject-addtask-only decorate_task( withMockExperiments(), withStub(TelemetryEnvironment, "setExperimentActive"), withStub(TelemetryEnvironment, "setExperimentInactive"), withSendEventSpy(), withMockPreferences(), async function testOverriddenAtEnrollNoChange({ mockPreferences }) { // Set up a situation where the user has changed the value of the pref away // from the default. Then run a default experiment that changes the // preference to the same value. // An arbitrary string preference that won't interact with Normandy. let pref = "extensions.recommendations.privacyPolicyUrl"; let defaultValue = Services.prefs.getCharPref(pref); mockPreferences.set(pref, "user-set-value", "user"); await PreferenceExperiments.start({ slug: "test-experiment", actionName: "someAction", branch: "experimental-branch", preferences: { [pref]: { preferenceValue: "experiment-value", preferenceType: "string", preferenceBranchType: "default", }, }, experimentType: "pref-test", }); is( Services.prefs.getCharPref(pref), "user-set-value", "User value should be preserved" ); is( Services.prefs.getDefaultBranch("").getCharPref(pref), defaultValue, "Default value should not have changed" ); const experiment = await PreferenceExperiments.get("test-experiment"); ok( experiment.preferences[pref].overridden, "Pref should be marked as overridden" ); } ).only();