diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js')
-rw-r--r-- | toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js | 2205 |
1 files changed, 2205 insertions, 0 deletions
diff --git a/toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js b/toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js new file mode 100644 index 0000000000..80c3cd79f2 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js @@ -0,0 +1,2205 @@ +"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(); |