/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ import { actionCreators as ac, actionTypes as at, actionUtils as au, } from "common/Actions.mjs"; import { mockServices, stubGlobals } from "test/jest/test-utils"; import { PrefsFeed } from "lib/PrefsFeed.sys.mjs"; const NIMBUS_FEATURES = [ "adsBackend", "newtab", "newtabInferredPersonalization", "newtabOhttpImages", "newtabSmartShortcuts", "newtabTrainhop", "newtabWidgets", "pocketNewtab", ]; function mockNimbusFeatures() { return Object.fromEntries( NIMBUS_FEATURES.map(name => [ name, { getAllVariables: jest.fn(), getAllEnrollments: jest.fn(), getVariable: jest.fn(), onUpdate: jest.fn(), offUpdate: jest.fn(), }, ]) ); } // sinon's calledWith matches a prefix of the recorded arguments, so the karma // original could assert on the pref name alone. jest compares every argument, // so name-only assertions look at the recorded names instead. function calledNames(mockFn) { return mockFn.mock.calls.map(([name]) => name); } describe("PrefsFeed", () => { let feed; let FAKE_PREFS; let services; let nimbusFeatures; let region; let selectableProfileService; let restoreGlobals; beforeEach(() => { FAKE_PREFS = new Map([ ["foo", 1], ["bar", 2], ["baz", { value: 1, skipBroadcast: true }], ["qux", { value: 1, skipBroadcast: true, alsoToPreloaded: true }], ]); // Services.vc.compare defaults to 0, i.e. a supported (>= 155) host, so // that the theme-picker backward-compat gate lets the existing // browserNovaEnabled assertions hold. services = mockServices(["prefs", "obs", "vc"]); nimbusFeatures = mockNimbusFeatures(); region = { home: "US", REGION_TOPIC: "browser-region-updated" }; selectableProfileService = { hasCreatedSelectableProfiles: jest.fn(() => false), }; restoreGlobals = stubGlobals({ NimbusFeatures: nimbusFeatures, PrivateBrowsingUtils: { enabled: true }, Region: region, SelectableProfileService: selectableProfileService, Services: services, // jsdom has no Temporal; init() reads Temporal.Now.instant(). Temporal: { Instant: { compare: jest.fn() }, Now: { instant: jest.fn() }, }, }); feed = new PrefsFeed(FAKE_PREFS); jest.spyOn(feed, "_setPref"); feed.store = { dispatch: jest.fn(), getState() { return this.state; }, }; // Setup for tests that don't call `init` feed._prefs = { get: jest.fn(item => FAKE_PREFS.get(item)), set: jest.fn((name, value) => FAKE_PREFS.set(name, value)), observe: jest.fn(), observeBranch: jest.fn(), ignore: jest.fn(), ignoreBranch: jest.fn(), locked: jest.fn(() => false), reset: jest.fn(), _branchStr: "branch.str.", }; }); afterEach(() => { restoreGlobals(); jest.restoreAllMocks(); }); it("should set a pref when a SET_PREF action is received", () => { feed.onAction(ac.SetPref("foo", 2)); expect(feed._prefs.set).toHaveBeenCalledWith("foo", 2); }); it("should set every pref when a SET_MULTIPLE_PREFS action is received", () => { feed.onAction(ac.SetMultiplePrefs({ foo: 2, bar: 3 })); expect(feed._prefs.set).toHaveBeenCalledWith("foo", 2); expect(feed._prefs.set).toHaveBeenCalledWith("bar", 3); }); it("should coalesce SET_MULTIPLE_PREFS into one content MULTIPLE_PREFS_CHANGED while still notifying feeds per pref", () => { // The branch observer fires onPrefChanged synchronously per _prefs.set. feed._prefs.set = jest.fn((name, value) => feed.onPrefChanged(name, value)); feed.onAction(ac.SetMultiplePrefs({ foo: 2, bar: 3 })); const dispatched = feed.store.dispatch.mock.calls.map(([action]) => action); // Content gets exactly one combined MULTIPLE_PREFS_CHANGED broadcast. const prefsChanged = dispatched.filter( a => a.type === at.MULTIPLE_PREFS_CHANGED ); expect(prefsChanged.length).toBe(1); expect(prefsChanged[0].data.values).toEqual({ foo: 2, bar: 3 }); expect(au.isBroadcastToContent(prefsChanged[0])).toBe(true); // Feeds still get per-pref PREF_CHANGED, but main-only (not re-broadcast // to content, which would re-stagger the resize). const prefChanged = dispatched.filter(a => a.type === at.PREF_CHANGED); expect(prefChanged.length).toBe(2); prefChanged.forEach(a => expect(au.isBroadcastToContent(a)).toBe(false)); }); it("should still route skipBroadcast prefs individually during a SET_MULTIPLE_PREFS transaction", () => { feed._prefs.set = jest.fn((name, value) => feed.onPrefChanged(name, value)); feed.onAction(ac.SetMultiplePrefs({ foo: 2, baz: 5 })); const dispatched = feed.store.dispatch.mock.calls.map(([action]) => action); const prefsChanged = dispatched.filter( a => a.type === at.MULTIPLE_PREFS_CHANGED ); expect(prefsChanged.length).toBe(1); expect(prefsChanged[0].data.values).toEqual({ foo: 2 }); const bazChange = dispatched.find( a => a.type === at.PREF_CHANGED && a.data.name === "baz" ); expect(bazChange).toBeTruthy(); // baz should be dispatched individually expect(bazChange.data.value).toBe(5); }); it("should call clearUserPref with action CLEAR_PREF", () => { feed.onAction({ type: at.CLEAR_PREF, data: { name: "pref.test" } }); expect(services.prefs.clearUserPref).toHaveBeenCalledWith( "branch.str.pref.test" ); }); it("should dispatch PREFS_INITIAL_VALUES on init with pref values and .isPrivateBrowsingEnabled", () => { feed.onAction({ type: at.INIT }); expect(feed.store.dispatch).toHaveBeenCalledTimes(1); expect(feed.store.dispatch.mock.calls[0][0].type).toBe( at.PREFS_INITIAL_VALUES ); const [[{ data }]] = feed.store.dispatch.mock.calls; expect(data.foo).toBe(1); expect(data.bar).toBe(2); expect(data.isPrivateBrowsingEnabled).toBe(true); }); it("should dispatch PREFS_INITIAL_VALUES with a .featureConfig", () => { nimbusFeatures.newtab.getAllVariables.mockReturnValue({ prefsButtonIcon: "icon-foo", }); feed.onAction({ type: at.INIT }); expect(feed.store.dispatch.mock.calls[0][0].type).toBe( at.PREFS_INITIAL_VALUES ); const [[{ data }]] = feed.store.dispatch.mock.calls; expect(data.featureConfig).toEqual({ prefsButtonIcon: "icon-foo" }); }); it("should dispatch PREFS_INITIAL_VALUES with trainhopConfig", () => { const testObject = { meta: { isRollout: false }, value: { type: "testExperiment", payload: { enabled: true }, }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ testObject, ]); feed.onAction({ type: at.INIT }); expect(feed.store.dispatch.mock.calls[0][0].type).toBe( at.PREFS_INITIAL_VALUES ); const [[{ data }]] = feed.store.dispatch.mock.calls; expect(data.trainhopConfig).toEqual({ testExperiment: { enabled: true }, }); }); it("should dispatch PREFS_INITIAL_VALUES with adsBackendConfig", () => { const testObject = { meta: { isRollout: false }, value: { flags: { feature1: true, }, }, }; nimbusFeatures.adsBackend.getAllEnrollments.mockReturnValue([testObject]); feed.onAction({ type: at.INIT }); expect(feed.store.dispatch.mock.calls[0][0].type).toBe( at.PREFS_INITIAL_VALUES ); const [[{ data }]] = feed.store.dispatch.mock.calls; expect(data.adsBackendConfig).toEqual({ feature1: true, }); }); it("should dispatch PREFS_INITIAL_VALUES with an empty object if no experiment is returned", () => { nimbusFeatures.newtab.getAllVariables.mockReturnValue(null); feed.onAction({ type: at.INIT }); expect(feed.store.dispatch.mock.calls[0][0].type).toBe( at.PREFS_INITIAL_VALUES ); const [[{ data }]] = feed.store.dispatch.mock.calls; expect(data.featureConfig).toEqual({}); }); describe("locked prefs", () => { it("should dispatch PREFS_INITIAL_VALUES with the locked prefs", () => { feed._prefs.locked = jest.fn(name => name === "bar"); feed.onAction({ type: at.INIT }); const [[action]] = feed.store.dispatch.mock.calls; expect(action.type).toBe(at.PREFS_INITIAL_VALUES); expect(action.data.lockedPrefs).toEqual(["bar"]); }); it("should broadcast the locked prefs when a pref's lock state changes", () => { feed.onAction({ type: at.INIT }); feed.store.dispatch.mockClear(); feed._prefs.locked = jest.fn(name => name === "foo"); feed.onPrefChanged("foo", 2); const action = feed.store.dispatch.mock.calls .map(([a]) => a) .find(a => a.type === at.PREF_CHANGED && a.data.name === "lockedPrefs"); expect(action.data.value).toEqual(["foo"]); expect(au.isBroadcastToContent(action)).toBe(true); }); it("should not re-broadcast the locked prefs when nothing was locked or unlocked", () => { feed.onAction({ type: at.INIT }); feed.store.dispatch.mockClear(); feed.onPrefChanged("foo", 2); expect( feed.store.dispatch.mock.calls .map(([a]) => a) .find( a => a.type === at.PREF_CHANGED && a.data.name === "lockedPrefs" ) ).toBeUndefined(); }); }); it("should add one branch observer on init", () => { feed.onAction({ type: at.INIT }); expect(feed._prefs.observeBranch).toHaveBeenCalledTimes(1); expect(feed._prefs.observeBranch).toHaveBeenCalledWith(feed); }); it("should handle region on init", () => { feed.init(); expect(feed.geo).toBe("US"); }); it("should add region observer on init", () => { region.home = ""; feed.init(); expect(feed.geo).toBe(""); expect(services.obs.addObserver).toHaveBeenCalledWith( feed, region.REGION_TOPIC ); }); it("should remove the branch observer on uninit", () => { feed.onAction({ type: at.UNINIT }); expect(feed._prefs.ignoreBranch).toHaveBeenCalledTimes(1); expect(feed._prefs.ignoreBranch).toHaveBeenCalledWith(feed); }); it("should call removeObserver", () => { feed.geo = ""; feed.uninit(); expect(services.obs.removeObserver).toHaveBeenCalledWith( feed, region.REGION_TOPIC ); }); describe("browserNovaEnabled", () => { it("should add a browser.nova.enabled observer on init", () => { feed.init(); expect(services.prefs.addObserver).toHaveBeenCalledWith( "browser.nova.enabled", feed ); }); it("should remove the browser.nova.enabled observer on uninit", () => { feed.uninit(); expect(services.prefs.removeObserver).toHaveBeenCalledWith( "browser.nova.enabled", feed ); }); it("should broadcast browserNovaEnabled when browser.nova.enabled changes", () => { services.prefs.getBoolPref.mockReturnValue(true); feed.observe(null, "nsPref:changed", "browser.nova.enabled"); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "browserNovaEnabled", value: true }, }) ); }); it("keeps browserNovaEnabled false on hosts older than 155 even when the pref is on", () => { services.prefs.getBoolPref.mockReturnValue(true); services.vc.compare.mockReturnValue(-1); feed.observe(null, "nsPref:changed", "browser.nova.enabled"); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "browserNovaEnabled", value: false }, }) ); }); }); describe("supportsWidgetSearchSap", () => { it("is true in the initial values on a host that knows the access point", () => { feed.onAction({ type: at.INIT }); const [[{ data }]] = feed.store.dispatch.mock.calls; expect(data.supportsWidgetSearchSap).toBe(true); }); it("is false in the initial values on a host older than 157", () => { services.vc.compare.mockReturnValue(-1); feed.onAction({ type: at.INIT }); const [[{ data }]] = feed.store.dispatch.mock.calls; expect(data.supportsWidgetSearchSap).toBe(false); }); }); describe("recordsHistory", () => { // The initial values are what the first new tab of a session reads, so the // widget-hiding depends on this being present before any pref changes. it("is included in the initial values when history is on", () => { services.prefs.getBoolPref = jest.fn(() => true); feed.onAction({ type: at.INIT }); const [[{ data }]] = feed.store.dispatch.mock.calls; expect(data.recordsHistory).toBe(true); }); it("is false in the initial values when history is off", () => { services.prefs.getBoolPref = jest.fn( pref => pref !== "places.history.enabled" ); feed.onAction({ type: at.INIT }); const [[{ data }]] = feed.store.dispatch.mock.calls; expect(data.recordsHistory).toBe(false); }); it("observes both history prefs on init and drops them on uninit", () => { feed.init(); feed.uninit(); for (const pref of [ "places.history.enabled", "browser.privatebrowsing.autostart", ]) { expect(services.prefs.addObserver).toHaveBeenCalledWith(pref, feed); expect(services.prefs.removeObserver).toHaveBeenCalledWith(pref, feed); } }); it("broadcasts the value when places.history.enabled changes", () => { services.prefs.getBoolPref = jest.fn(() => false); feed.observe(null, "nsPref:changed", "places.history.enabled"); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "recordsHistory", value: false }, }) ); }); it("ignores a pref write that leaves the derived value alone", () => { // Two prefs collapse into one boolean, so moving autostart while // places.history.enabled is already false changes nothing. services.prefs.getBoolPref = jest.fn( pref => pref !== "places.history.enabled" ); feed.init(); feed.store.dispatch.mockClear(); feed.observe(null, "nsPref:changed", "browser.privatebrowsing.autostart"); expect(feed.store.dispatch).not.toHaveBeenCalled(); }); it("broadcasts once per flip, not once per write", () => { services.prefs.getBoolPref = jest.fn(() => true); feed.init(); feed.store.dispatch.mockClear(); services.prefs.getBoolPref = jest.fn( pref => pref !== "places.history.enabled" ); feed.observe(null, "nsPref:changed", "places.history.enabled"); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "recordsHistory", value: false }, }) ); // A second write with the same resulting value is a no-op. feed.store.dispatch.mockClear(); feed.observe(null, "nsPref:changed", "places.history.enabled"); expect(feed.store.dispatch).not.toHaveBeenCalled(); }); it("is false in permanent private browsing", () => { // Places records no visits there, so the pref alone is not enough. globalThis.PrivateBrowsingUtils.permanentPrivateBrowsing = true; services.prefs.getBoolPref = jest.fn(() => true); feed.observe(null, "nsPref:changed", "browser.privatebrowsing.autostart"); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "recordsHistory", value: false }, }) ); }); }); it("should send a PREF_CHANGED action when onPrefChanged is called", () => { feed.onPrefChanged("foo", 2); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "foo", value: 2 }, }) ); }); it("should send a PREF_CHANGED actions when onPocketExperimentUpdated is called", () => { nimbusFeatures.pocketNewtab.getAllVariables.mockReturnValue({ prefsButtonIcon: "icon-new", }); feed.onPocketExperimentUpdated(); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "pocketConfig", value: { prefsButtonIcon: "icon-new", }, }, }) ); }); it("should not send a PREF_CHANGED actions when onPocketExperimentUpdated is called during startup", () => { nimbusFeatures.pocketNewtab.getAllVariables.mockReturnValue({ prefsButtonIcon: "icon-new", }); feed.onPocketExperimentUpdated({}, "feature-experiment-loaded"); expect(feed.store.dispatch).not.toHaveBeenCalled(); feed.onPocketExperimentUpdated({}, "feature-rollout-loaded"); expect(feed.store.dispatch).not.toHaveBeenCalled(); }); it("should set initialWallpaper when currentWallpaper is set and initialWallpaper is unset", () => { nimbusFeatures.pocketNewtab.getAllVariables.mockReturnValue({ currentWallpaper: "celestial", }); feed.onPocketExperimentUpdated(); expect(feed._prefs.set).toHaveBeenCalledWith( "newtabWallpapers.initialWallpaper", "celestial" ); }); it("should not overwrite initialWallpaper if it is already set", () => { FAKE_PREFS.set("newtabWallpapers.initialWallpaper", "celestial"); nimbusFeatures.pocketNewtab.getAllVariables.mockReturnValue({ currentWallpaper: "celestial", }); feed.onPocketExperimentUpdated(); expect(calledNames(feed._prefs.set)).not.toContain( "newtabWallpapers.initialWallpaper" ); }); it("should send a PREF_CHANGED actions when onExperimentUpdated is called", () => { nimbusFeatures.newtab.getAllVariables.mockReturnValue({ prefsButtonIcon: "icon-new", }); feed.onExperimentUpdated(); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "featureConfig", value: { prefsButtonIcon: "icon-new", }, }, }) ); }); describe("spaces opt-out mirror", () => { beforeEach(() => { FAKE_PREFS.set("pageLayouts.variant", "spaces-buttons-bottom"); }); it("should opt the space out when its pref is turned off", () => { feed.onPrefChanged("feeds.section.topstories", false); expect(feed._prefs.set).toHaveBeenCalledWith( "spaces.storiesOptOut", true ); }); it("should opt back in when the pref is turned on again", () => { FAKE_PREFS.set("spaces.storiesOptOut", true); feed.onPrefChanged("feeds.section.topstories", true); expect(feed._prefs.set).toHaveBeenCalledWith( "spaces.storiesOptOut", false ); }); it("should mirror every space the same way", () => { feed.onPrefChanged("widgets.enabled", false); feed.onPrefChanged("feeds.section.highlights", false); expect(feed._prefs.set).toHaveBeenCalledWith( "spaces.widgetsOptOut", true ); expect(feed._prefs.set).toHaveBeenCalledWith( "spaces.activityOptOut", true ); }); it("should mirror a change made outside the newtab page", () => { // about:preferences writes through the Preferences binding rather than // SET_PREF, so only the branch observer sees it. feed.observe(null, "nsPref:changed", "feeds.section.topstories"); feed.onPrefChanged("feeds.section.topstories", false); expect(feed._prefs.set).toHaveBeenCalledWith( "spaces.storiesOptOut", true ); }); it("should not write when the mirror already matches", () => { FAKE_PREFS.set("spaces.storiesOptOut", true); feed.onPrefChanged("feeds.section.topstories", false); expect(calledNames(feed._prefs.set)).not.toContain( "spaces.storiesOptOut" ); }); it("should not mirror outside the experiment", () => { FAKE_PREFS.set("pageLayouts.variant", "nova-full-width"); feed.onPrefChanged("feeds.section.topstories", false); expect(calledNames(feed._prefs.set)).not.toContain( "spaces.storiesOptOut" ); }); it("should not mirror a pref that is not a space", () => { feed.onPrefChanged("feeds.topsites", false); expect( calledNames(feed._prefs.set).some(name => name.startsWith("spaces.")) ).toBe(false); }); }); describe("newtabTrainhop", () => { it("should send a PREF_CHANGED actions when onTrainhopExperimentUpdated is called", () => { const testObject = { meta: { isRollout: false, }, value: { type: "testExperiment", payload: { enabled: true, }, }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ testObject, ]); feed.onTrainhopExperimentUpdated(); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "trainhopConfig", value: { testExperiment: testObject.value.payload, }, }, }) ); }); it("should handle and dedupe multiple experiments and rollouts", () => { const testObject1 = { meta: { isRollout: false, }, value: { type: "testExperiment1", payload: { enabled: true, }, }, }; const testObject2 = { meta: { isRollout: false, }, value: { type: "testExperiment1", payload: { enabled: false, }, }, }; const testObject3 = { meta: { isRollout: true, }, value: { type: "testExperiment2", payload: { enabled: true, }, }, }; const testObject4 = { meta: { isRollout: false, }, value: { type: "testExperiment2", payload: { enabled: false, }, }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ testObject1, testObject2, testObject3, testObject4, ]); feed.onTrainhopExperimentUpdated(); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "trainhopConfig", value: { testExperiment1: testObject1.value.payload, testExperiment2: testObject4.value.payload, }, }, }) ); }); it("should handle multi-payload format with single enrollment", () => { const testObject = { meta: { isRollout: false, }, value: { type: "multi-payload", payload: [ { type: "testExperiment", payload: { enabled: true, name: "test-name", }, }, ], }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ testObject, ]); feed.onTrainhopExperimentUpdated(); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "trainhopConfig", value: { testExperiment: { enabled: true, name: "test-name", }, }, }, }) ); }); it("should handle multi-payload format with multiple items in single enrollment", () => { const testObject = { meta: { isRollout: false, }, value: { type: "multi-payload", payload: [ { type: "testExperiment1", payload: { enabled: true, }, }, { type: "testExperiment2", payload: { enabled: false, }, }, ], }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ testObject, ]); feed.onTrainhopExperimentUpdated(); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "trainhopConfig", value: { testExperiment1: { enabled: true, }, testExperiment2: { enabled: false, }, }, }, }) ); }); it("should write trainhop widgets.weatherSize to the default branch", () => { const setStringPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setStringPref, setBoolPref: jest.fn(), }); const enrollment = { meta: { isRollout: false }, value: { type: "widgets", payload: { weatherSize: "large" }, }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ enrollment, ]); feed.onTrainhopExperimentUpdated(); expect(setStringPref).toHaveBeenCalledWith( "widgets.weather.size", "large" ); }); it("should write widgetsSettings default-enabled values to the default branch", () => { const setBoolPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setBoolPref, setStringPref: jest.fn(), }); const enrollment = { meta: { isRollout: false }, value: { type: "widgetsSettings", payload: { listsEnabled: false, focusTimerEnabled: true }, }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ enrollment, ]); feed.onTrainhopExperimentUpdated(); expect(setBoolPref).toHaveBeenCalledWith("widgets.lists.enabled", false); expect(setBoolPref).toHaveBeenCalledWith( "widgets.focusTimer.enabled", true ); }); it("should write the Recent Activity default for the spaces experiment", () => { // Scoped to the experiment, so an unrelated train-hop config leaves it be. FAKE_PREFS.set("pageLayouts.variant", "spaces-buttons-bottom"); const setBoolPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setBoolPref, setStringPref: jest.fn(), }); nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ { meta: { isRollout: false }, value: { type: "highlights", payload: { enabled: true } }, }, ]); feed.onTrainhopExperimentUpdated(); // The default branch, so a user who has switched Recent Activity off // keeps it off: the user branch always wins. expect(setBoolPref).toHaveBeenCalledWith( "feeds.section.highlights", true ); }); it("should put the Recent Activity default back on unenrollment", () => { FAKE_PREFS.set("pageLayouts.variant", "spaces-buttons-bottom"); const setBoolPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setBoolPref, setStringPref: jest.fn(), }); const { getAllEnrollments } = nimbusFeatures.newtabTrainhop; getAllEnrollments.mockReturnValue([ { meta: { isRollout: false }, value: { type: "highlights", payload: { enabled: true } }, }, ]); feed.onTrainhopExperimentUpdated(); // Unenrolling takes the variant with it, so the revert has to run when // spaces is no longer assigned -- which is the whole point of it. getAllEnrollments.mockReturnValue([]); FAKE_PREFS.set("pageLayouts.variant", "nova-full-width"); feed.onTrainhopExperimentUpdated(); // Reverted in the same update rather than at the next restart, but only // because this profile was enrolled. expect(setBoolPref).toHaveBeenCalledWith( "feeds.section.highlights", false ); }); it("should not opt the space out when unenrolling reverts its default", () => { // The revert writes feeds.section.highlights, which fires the branch // observer. Its handler has to see the fresh config, or it reads our own // write as the user turning Recent Activity off. const setBoolPref = jest.fn((name, value) => feed.onPrefChanged(name, value) ); services.prefs.getDefaultBranch.mockReturnValue({ setBoolPref, setStringPref: jest.fn(), }); const { getAllEnrollments } = nimbusFeatures.newtabTrainhop; FAKE_PREFS.set("pageLayouts.variant", "spaces-buttons-bottom"); getAllEnrollments.mockReturnValue([ { meta: { isRollout: false }, value: { type: "multi-payload", payload: [ { type: "pageLayouts", payload: { variant: "spaces-buttons-bottom" }, }, { type: "highlights", payload: { enabled: true } }, ], }, }, ]); feed.onTrainhopExperimentUpdated(); getAllEnrollments.mockReturnValue([]); FAKE_PREFS.set("pageLayouts.variant", "nova-full-width"); feed.onTrainhopExperimentUpdated(); expect(feed._prefs.set).not.toHaveBeenCalledWith( "spaces.activityOptOut", true ); }); it("should not revert a default this profile was never given", () => { const setBoolPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setBoolPref, setStringPref: jest.fn(), }); nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([]); FAKE_PREFS.set("pageLayouts.variant", "nova-full-width"); feed.onTrainhopExperimentUpdated(); expect(calledNames(setBoolPref)).not.toContain( "feeds.section.highlights" ); }); it("should not touch the Recent Activity default for an unrelated config", () => { FAKE_PREFS.set("pageLayouts.variant", "spaces-buttons-bottom"); const setBoolPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setBoolPref, setStringPref: jest.fn(), }); nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ { meta: { isRollout: false }, value: { type: "highlights", payload: {} }, }, ]); feed.onTrainhopExperimentUpdated(); expect(calledNames(setBoolPref)).not.toContain( "feeds.section.highlights" ); }); it("should not write a widget default when its widgetsSettings key is absent", () => { const setBoolPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setBoolPref, setStringPref: jest.fn(), }); const enrollment = { meta: { isRollout: false }, value: { type: "widgetsSettings", payload: { listsEnabled: false }, }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ enrollment, ]); feed.onTrainhopExperimentUpdated(); expect(calledNames(setBoolPref)).not.toContain("widgets.clocks.enabled"); }); it("should write widgetPictureOfTheDay.enabled to the user pref default branch, not the system pref", () => { const setBoolPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setBoolPref, setStringPref: jest.fn(), }); const enrollment = { meta: { isRollout: false }, value: { type: "widgetPictureOfTheDay", payload: { enabled: true, setAsWallpaperEnabled: true, size: "large", }, }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ enrollment, ]); feed.onTrainhopExperimentUpdated(); // `enabled` overrides the user-facing enabled pref's default; `visible` // (not present here) would reveal the widget separately; size and // setAsWallpaperEnabled are read directly from trainhopConfig. expect(setBoolPref).toHaveBeenCalledWith( "widgets.pictureOfTheDay.enabled", true ); expect(calledNames(setBoolPref)).not.toContain( "widgets.system.pictureOfTheDay.enabled" ); expect(calledNames(setBoolPref)).not.toContain( "widgets.pictureOfTheDay.setAsWallpaper.enabled" ); }); it("should not write the POTD enabled default when widgetPictureOfTheDay.enabled is absent", () => { const setBoolPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setBoolPref, setStringPref: jest.fn(), }); const enrollment = { meta: { isRollout: false }, value: { type: "widgetPictureOfTheDay", payload: { size: "large" }, }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ enrollment, ]); feed.onTrainhopExperimentUpdated(); expect(calledNames(setBoolPref)).not.toContain( "widgets.pictureOfTheDay.enabled" ); }); it("should write widgetCrossword.enabled to the user pref default branch, not the system pref", () => { const setBoolPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setBoolPref, setStringPref: jest.fn(), }); const enrollment = { meta: { isRollout: false }, value: { type: "widgetCrossword", payload: { enabled: true, endpoint: "https://example.com/crossword/index.html", size: "large", }, }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ enrollment, ]); feed.onTrainhopExperimentUpdated(); // `enabled` overrides the user-facing enabled pref's default; `visible` // (not present here) would reveal the widget separately; size and // endpoint are read directly from trainhopConfig. expect(setBoolPref).toHaveBeenCalledWith( "widgets.crossword.enabled", true ); expect(calledNames(setBoolPref)).not.toContain( "widgets.system.crossword.enabled" ); }); it("should write widgetPrivacy.enabled to the user pref default branch, not the system pref", () => { const setBoolPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setBoolPref, setStringPref: jest.fn(), }); nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ { meta: { isRollout: false }, value: { type: "widgetPrivacy", payload: { enabled: true, showVpnMessages: true, maxDisplayCount: 50, size: "large", }, }, }, ]); feed.onTrainhopExperimentUpdated(); // `enabled` overrides the user-facing enabled pref's default; `visible` // (not present here) would reveal the widget separately; size and the // message-scheduling keys are read directly from trainhopConfig. expect(setBoolPref).toHaveBeenCalledWith("widgets.privacy.enabled", true); expect(calledNames(setBoolPref)).not.toContain( "widgets.system.privacy.enabled" ); }); it("should not write the privacy enabled default when widgetPrivacy.enabled is absent", () => { const setBoolPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setBoolPref, setStringPref: jest.fn(), }); nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ { meta: { isRollout: false }, value: { type: "widgetPrivacy", payload: { showVpnMessages: true }, }, }, ]); feed.onTrainhopExperimentUpdated(); expect(calledNames(setBoolPref)).not.toContain("widgets.privacy.enabled"); }); it("should not write the crossword enabled default when widgetCrossword.enabled is absent", () => { const setBoolPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setBoolPref, setStringPref: jest.fn(), }); const enrollment = { meta: { isRollout: false }, value: { type: "widgetCrossword", payload: { size: "large" }, }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ enrollment, ]); feed.onTrainhopExperimentUpdated(); expect(calledNames(setBoolPref)).not.toContain( "widgets.crossword.enabled" ); }); it("should not write widgets.weather.size when weatherSize is missing", () => { const setStringPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setStringPref, setBoolPref: jest.fn(), }); const enrollment = { meta: { isRollout: false }, value: { type: "widgets", payload: { enabled: true }, }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ enrollment, ]); feed.onTrainhopExperimentUpdated(); expect(calledNames(setStringPref)).not.toContain("widgets.weather.size"); }); it("should not write widgets.weather.size when weatherSize is empty string", () => { const setStringPref = jest.fn(); services.prefs.getDefaultBranch.mockReturnValue({ setStringPref, setBoolPref: jest.fn(), }); const enrollment = { meta: { isRollout: false }, value: { type: "widgets", payload: { weatherSize: "" }, }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ enrollment, ]); feed.onTrainhopExperimentUpdated(); expect(calledNames(setStringPref)).not.toContain("widgets.weather.size"); }); it("should dedupe multi-payload format with experiment taking precedence over rollout", () => { const rollout = { meta: { isRollout: true, }, value: { type: "multi-payload", payload: [ { type: "testExperiment", payload: { enabled: false, name: "rollout-name", }, }, ], }, }; const experiment = { meta: { isRollout: false, }, value: { type: "multi-payload", payload: [ { type: "testExperiment", payload: { enabled: true, name: "experiment-name", }, }, ], }, }; nimbusFeatures.newtabTrainhop.getAllEnrollments.mockReturnValue([ rollout, experiment, ]); feed.onTrainhopExperimentUpdated(); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "trainhopConfig", value: { testExperiment: { enabled: true, name: "experiment-name", }, }, }, }) ); }); }); describe("adsBackend", () => { it("should send a PREF_CHANGED action when onAdsBackendUpdated is called", () => { const testObject = { meta: { isRollout: false }, value: { flags: { feature1: true, }, }, }; nimbusFeatures.adsBackend.getAllEnrollments.mockReturnValue([testObject]); feed.onAdsBackendUpdated(); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "adsBackendConfig", value: { feature1: true, }, }, }) ); }); it("should prefer experiments over rollouts for individual flags", () => { const testObject1 = { meta: { isRollout: false }, value: { flags: { feature1: true, }, }, }; const testObject2 = { meta: { isRollout: true }, value: { flags: { feature1: false, feature2: true, }, }, }; const testObject3 = { meta: { isRollout: false }, value: { flags: { feature2: false, }, }, }; nimbusFeatures.adsBackend.getAllEnrollments.mockReturnValue([ testObject1, testObject2, testObject3, ]); feed.onAdsBackendUpdated(); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "adsBackendConfig", value: { feature1: true, feature2: false, }, }, }) ); }); it("should handle and merge multiple experiments and rollouts", () => { const testObject1 = { meta: { isRollout: false }, value: { flags: { feature1: true, feature2: true, }, }, }; const testObject2 = { meta: { isRollout: true }, value: { flags: { feature1: false, }, }, }; const testObject3 = { meta: { isRollout: true }, value: { flags: { feature3: true, }, }, }; const testObject4 = { meta: { isRollout: false }, value: { flags: { feature3: false, feature4: true, }, }, }; nimbusFeatures.adsBackend.getAllEnrollments.mockReturnValue([ testObject1, testObject2, testObject3, testObject4, ]); feed.onAdsBackendUpdated(); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "adsBackendConfig", value: { feature1: true, feature2: true, feature3: false, feature4: true, }, }, }) ); }); it("should handle no active experiments and rollouts", () => { nimbusFeatures.adsBackend.getAllEnrollments.mockReturnValue([]); feed.onAdsBackendUpdated(); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "adsBackendConfig", value: {}, }, }) ); }); }); it("should dispatch PREF_CHANGED when onWidgetsUpdated is called", () => { nimbusFeatures.newtabWidgets.getAllVariables.mockReturnValue({ enabled: true, listsEnabled: true, timerEnabled: false, }); feed.onWidgetsUpdated(); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.BroadcastToContent({ type: at.PREF_CHANGED, data: { name: "widgetsConfig", value: { enabled: true, listsEnabled: true, timerEnabled: false, }, }, }) ); }); it("should remove all events on removeListeners", () => { feed.geo = ""; feed.removeListeners(); expect(nimbusFeatures.pocketNewtab.offUpdate).toHaveBeenCalledWith( feed.onPocketExperimentUpdated ); expect(nimbusFeatures.newtab.offUpdate).toHaveBeenCalledWith( feed.onExperimentUpdated ); expect(nimbusFeatures.newtabTrainhop.offUpdate).toHaveBeenCalledWith( feed.onTrainhopExperimentUpdated ); expect(nimbusFeatures.adsBackend.offUpdate).toHaveBeenCalledWith( feed.onAdsBackendUpdated ); expect(services.obs.removeObserver).toHaveBeenCalledWith( feed, region.REGION_TOPIC ); }); it("should send OnlyToMain pref update if config for pref has skipBroadcast: true", async () => { feed.onPrefChanged("baz", { value: 2, skipBroadcast: true }); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.OnlyToMain({ type: at.PREF_CHANGED, data: { name: "baz", value: { value: 2, skipBroadcast: true } }, }) ); }); it("should send AlsoToPreloaded pref update if config for pref has skipBroadcast: true and alsoToPreloaded: true", async () => { feed.onPrefChanged("qux", { value: 2, skipBroadcast: true, alsoToPreloaded: true, }); expect(feed.store.dispatch).toHaveBeenCalledWith( ac.AlsoToPreloaded({ type: at.PREF_CHANGED, data: { name: "qux", value: { value: 2, skipBroadcast: true, alsoToPreloaded: true }, }, }) ); }); describe("#observe", () => { it("should call dispatch from observe", () => { feed.observe(undefined, region.REGION_TOPIC); expect(feed.store.dispatch).toHaveBeenCalledTimes(1); }); }); describe("#_setStringPref", () => { it("should call _setPref and getStringPref from _setStringPref", () => { // The shared stub returns the caller's default; this case asserts the // pass-through of an unset pref, so make it read as unset. services.prefs.getStringPref.mockReturnValueOnce(undefined); feed._setStringPref({}, "fake.pref", "default"); expect(feed._setPref).toHaveBeenCalledTimes(1); expect(feed._setPref).toHaveBeenCalledWith( { "fake.pref": undefined }, "fake.pref", "default", services.prefs.getStringPref ); expect(services.prefs.getStringPref).toHaveBeenCalledTimes(1); expect(services.prefs.getStringPref).toHaveBeenCalledWith( "browser.newtabpage.activity-stream.fake.pref", "default" ); }); }); describe("#_setBoolPref", () => { it("should call _setPref and getBoolPref from _setBoolPref", () => { // The shared stub returns the caller's default; this case asserts the // pass-through of an unset pref, so make it read as unset. services.prefs.getBoolPref.mockReturnValueOnce(undefined); feed._setBoolPref({}, "fake.pref", false); expect(feed._setPref).toHaveBeenCalledTimes(1); expect(feed._setPref).toHaveBeenCalledWith( { "fake.pref": undefined }, "fake.pref", false, services.prefs.getBoolPref ); expect(services.prefs.getBoolPref).toHaveBeenCalledTimes(1); expect(services.prefs.getBoolPref).toHaveBeenCalledWith( "browser.newtabpage.activity-stream.fake.pref", false ); }); }); describe("#_setIntPref", () => { it("should call _setPref and getIntPref from _setIntPref", () => { // The shared stub returns the caller's default; this case asserts the // pass-through of an unset pref, so make it read as unset. services.prefs.getIntPref.mockReturnValueOnce(undefined); feed._setIntPref({}, "fake.pref", 1); expect(feed._setPref).toHaveBeenCalledTimes(1); expect(feed._setPref).toHaveBeenCalledWith( { "fake.pref": undefined }, "fake.pref", 1, services.prefs.getIntPref ); expect(services.prefs.getIntPref).toHaveBeenCalledTimes(1); expect(services.prefs.getIntPref).toHaveBeenCalledWith( "browser.newtabpage.activity-stream.fake.pref", 1 ); }); }); describe("#_setPref", () => { it("should set pref value with _setPref", () => { const getPrefFunctionSpy = jest.fn(); const values = {}; feed._setPref(values, "fake.pref", "default", getPrefFunctionSpy); expect(values).toEqual({ "fake.pref": undefined }); expect(getPrefFunctionSpy).toHaveBeenCalledTimes(1); expect(getPrefFunctionSpy).toHaveBeenCalledWith( "browser.newtabpage.activity-stream.fake.pref", "default" ); }); }); describe("Activation Window Evaluation", () => { let mockCreatedInstant; let mockNowInstant; let defaultBranch; let temporal; let aboutNewTab; let restoreActivationGlobals; const TEST_VARIANT = "a"; beforeEach(() => { // Mock Temporal.Instant for time control // Create a mock instant representing profile creation time (Jan 1, 2024) mockCreatedInstant = { toString: () => "2024-01-01T00:00:00Z", }; // Mock "now" as 24 hours after creation mockNowInstant = { toString: () => "2024-01-02T00:00:00Z", subtract: jest.fn(() => ({ toString: () => "2023-12-30T00:00:00Z", })), }; temporal = { Instant: { compare: jest.fn(), }, Now: { instant: jest.fn(() => mockNowInstant), }, }; aboutNewTab = { activityStream: { createdInstant: mockCreatedInstant, }, }; restoreActivationGlobals = stubGlobals({ AboutNewTab: aboutNewTab, Temporal: temporal, }); defaultBranch = { setBoolPref: jest.fn(), }; services.prefs.getDefaultBranch.mockReturnValue(defaultBranch); // Setup store state with a fake activation window config feed.store.state = { Prefs: { values: { trainhopConfig: { activationWindowBehavior: { enabled: true, maxProfileAgeInHours: 48, disableTopSites: true, disableTopStories: true, variant: TEST_VARIANT, enterActivationWindowMessageID: "", exitActivationWindowMessageID: "", }, }, }, }, }; jest.spyOn(feed, "enterActivationWindowState"); jest.spyOn(feed, "exitActivationWindowState"); }); afterEach(() => { restoreActivationGlobals(); }); describe("#checkForActivationWindow", () => { it("should enter activation window state when profile is within window", () => { // First call: createdInstant < now (comparison returns -1) // Second call: createdInstant > (now - 48 hours) (comparison returns 1) temporal.Instant.compare.mockReturnValueOnce(-1).mockReturnValueOnce(1); feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).toHaveBeenCalledTimes(1); expect(feed.enterActivationWindowState).toHaveBeenCalledWith( TEST_VARIANT, true, true, "", false ); }); it("should exit activation window state when profile is outside window", () => { feed.inActivationWindowState = TEST_VARIANT; feed._prefs.isSet = jest.fn(() => false); // First call: createdInstant < now (comparison returns -1) // Second call: createdInstant < (now - 48 hours) (comparison returns -1, meaning too old) temporal.Instant.compare .mockReturnValueOnce(-1) .mockReturnValueOnce(-1); feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).not.toHaveBeenCalled(); expect(feed.exitActivationWindowState).toHaveBeenCalledTimes(1); }); it("should not enter activation window when profile is in the future", () => { // First call: createdInstant > now (comparison returns 1) temporal.Instant.compare.mockReturnValueOnce(1); feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).not.toHaveBeenCalled(); expect(feed.exitActivationWindowState).not.toHaveBeenCalled(); }); it("should not enter activation window when profile is exactly at boundary", () => { // First call: createdInstant < now (comparison returns -1) // Second call: createdInstant === (now - 48 hours) (comparison returns 0) temporal.Instant.compare.mockReturnValueOnce(-1).mockReturnValueOnce(0); feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).not.toHaveBeenCalled(); }); it("should not evaluate when config is disabled", () => { feed.store.state.Prefs.values.trainhopConfig.activationWindowBehavior.enabled = false; feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).not.toHaveBeenCalled(); expect(feed.exitActivationWindowState).not.toHaveBeenCalled(); }); it("should not enter the activation window if 1 or more selectable profiles have been created", () => { // First call: createdInstant < now (comparison returns -1) // Second call: createdInstant > (now - 48 hours) (comparison returns 1) temporal.Instant.compare.mockReturnValueOnce(-1).mockReturnValueOnce(1); selectableProfileService.hasCreatedSelectableProfiles.mockReturnValue( true ); feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).not.toHaveBeenCalled(); expect(feed.exitActivationWindowState).not.toHaveBeenCalled(); }); it("should exit activation window state when config is disabled but currently in state", () => { feed.inActivationWindowState = TEST_VARIANT; feed.store.state.Prefs.values.trainhopConfig.activationWindowBehavior.enabled = false; feed._prefs.isSet = jest.fn(() => false); feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).not.toHaveBeenCalled(); expect(feed.exitActivationWindowState).toHaveBeenCalledTimes(1); }); it("should not evaluate when createdInstant is missing", () => { aboutNewTab.activityStream.createdInstant = null; feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).not.toHaveBeenCalled(); expect(feed.exitActivationWindowState).not.toHaveBeenCalled(); }); it("should exit activation window state when createdInstant is missing but currently in state", () => { feed.inActivationWindowState = TEST_VARIANT; aboutNewTab.activityStream.createdInstant = null; feed._prefs.isSet = jest.fn(() => false); feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).not.toHaveBeenCalled(); expect(feed.exitActivationWindowState).toHaveBeenCalledTimes(1); }); it("should not evaluate when variant is missing", () => { feed.store.state.Prefs.values.trainhopConfig.activationWindowBehavior.variant = ""; feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).not.toHaveBeenCalled(); expect(feed.exitActivationWindowState).not.toHaveBeenCalled(); }); it("should exit activation window state when variant is missing but currently in state", () => { feed.inActivationWindowState = TEST_VARIANT; feed.store.state.Prefs.values.trainhopConfig.activationWindowBehavior.variant = ""; feed._prefs.isSet = jest.fn(() => false); feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).not.toHaveBeenCalled(); expect(feed.exitActivationWindowState).toHaveBeenCalledTimes(1); }); it("should return early when store state is missing", () => { feed.store.state = null; feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).not.toHaveBeenCalled(); expect(feed.exitActivationWindowState).not.toHaveBeenCalled(); }); it("should return early when Prefs state is missing", () => { feed.store.state = { Prefs: null }; feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).not.toHaveBeenCalled(); expect(feed.exitActivationWindowState).not.toHaveBeenCalled(); }); it("should call enterActivationWindowState even if already in state", () => { feed.inActivationWindowState = TEST_VARIANT; // First call: createdInstant < now (comparison returns -1) // Second call: createdInstant > (now - 48 hours) (comparison returns 1) temporal.Instant.compare.mockReturnValueOnce(-1).mockReturnValueOnce(1); feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).toHaveBeenCalledTimes(1); expect(feed.exitActivationWindowState).not.toHaveBeenCalled(); }); it("should use default now instant when not provided", () => { // Set up for within window temporal.Instant.compare.mockReturnValueOnce(-1).mockReturnValueOnce(1); feed.checkForActivationWindow(); expect(temporal.Now.instant).toHaveBeenCalledTimes(1); expect(feed.enterActivationWindowState).toHaveBeenCalledTimes(1); }); it("should pass isStartup=true to enterActivationWindowState when called with isStartup=true", () => { // Set up for within window temporal.Instant.compare.mockReturnValueOnce(-1).mockReturnValueOnce(1); feed.checkForActivationWindow(mockNowInstant, /* isStartup */ true); expect(feed.enterActivationWindowState).toHaveBeenCalledTimes(1); expect(feed.enterActivationWindowState).toHaveBeenCalledWith( TEST_VARIANT, true, true, "", true ); }); it("should pass isStartup=false to enterActivationWindowState when called without isStartup", () => { // Set up for within window temporal.Instant.compare.mockReturnValueOnce(-1).mockReturnValueOnce(1); feed.checkForActivationWindow(mockNowInstant); expect(feed.enterActivationWindowState).toHaveBeenCalledTimes(1); expect(feed.enterActivationWindowState).toHaveBeenCalledWith( TEST_VARIANT, true, true, "", false ); }); }); }); describe("Activation Window Broadcasting", () => { const TEST_VARIANT = "a"; let defaultBranch; beforeEach(() => { defaultBranch = { setBoolPref: jest.fn(), }; services.prefs.getDefaultBranch.mockReturnValue(defaultBranch); jest.spyOn(feed, "onPrefChanged"); }); describe("#enterActivationWindowState", () => { it("should broadcast pref changes when entering activation window", () => { feed.inActivationWindowState = ""; feed.enterActivationWindowState(TEST_VARIANT, true, true, ""); expect(feed.onPrefChanged).toHaveBeenCalledTimes(2); expect(feed.onPrefChanged).toHaveBeenCalledWith( "feeds.topsites", false, false ); expect(feed.onPrefChanged).toHaveBeenCalledWith( "feeds.section.topstories", false, false ); }); it("should not broadcast when already in the same variant", () => { feed.inActivationWindowState = TEST_VARIANT; feed.enterActivationWindowState(TEST_VARIANT, true, true, ""); expect(feed.onPrefChanged).not.toHaveBeenCalled(); expect(defaultBranch.setBoolPref).not.toHaveBeenCalled(); }); it("should broadcast when entering with different variant", () => { feed.inActivationWindowState = "variant-a"; feed.enterActivationWindowState("variant-b", true, true, ""); expect(feed.onPrefChanged).toHaveBeenCalledTimes(2); }); it("should only broadcast for top sites if only disabling top sites", () => { feed.inActivationWindowState = ""; feed.enterActivationWindowState(TEST_VARIANT, true, false, ""); expect(feed.onPrefChanged).toHaveBeenCalledTimes(1); expect(feed.onPrefChanged).toHaveBeenCalledWith( "feeds.topsites", false, false ); }); it("should only broadcast for top stories if only disabling top stories", () => { feed.inActivationWindowState = ""; feed.enterActivationWindowState(TEST_VARIANT, false, true, ""); expect(feed.onPrefChanged).toHaveBeenCalledTimes(1); expect(feed.onPrefChanged).toHaveBeenCalledWith( "feeds.section.topstories", false, false ); }); it("should not broadcast if not disabling anything", () => { feed.inActivationWindowState = ""; feed.enterActivationWindowState(TEST_VARIANT, false, false, ""); expect(feed.onPrefChanged).not.toHaveBeenCalled(); }); it("should reapply prefs on startup even when already in the same variant", () => { feed.inActivationWindowState = TEST_VARIANT; feed.enterActivationWindowState( TEST_VARIANT, true, true, "", /* isStartup */ true ); expect(defaultBranch.setBoolPref).toHaveBeenCalledTimes(2); expect(defaultBranch.setBoolPref).toHaveBeenCalledWith( "feeds.topsites", false ); expect(defaultBranch.setBoolPref).toHaveBeenCalledWith( "feeds.section.topstories", false ); }); it("should broadcast on startup even when already in the same variant", () => { feed.inActivationWindowState = TEST_VARIANT; feed.enterActivationWindowState( TEST_VARIANT, true, true, "", /* isStartup */ true ); expect(feed.onPrefChanged).toHaveBeenCalledTimes(2); expect(feed.onPrefChanged).toHaveBeenCalledWith( "feeds.topsites", false, false ); expect(feed.onPrefChanged).toHaveBeenCalledWith( "feeds.section.topstories", false, false ); }); it("should skip idempotent check when isStartup=true", () => { feed.inActivationWindowState = TEST_VARIANT; feed.enterActivationWindowState( TEST_VARIANT, true, false, "", /* isStartup */ true ); expect(feed.onPrefChanged).toHaveBeenCalledTimes(1); expect(feed.onPrefChanged).toHaveBeenCalledWith( "feeds.topsites", false, false ); }); it("should set enter message ID pref when provided", () => { feed.inActivationWindowState = ""; feed.enterActivationWindowState( "test-variant", true, true, "ENTER_MESSAGE_ID" ); expect(feed._prefs.set).toHaveBeenCalledWith( "activationWindow.enterMessageID", "ENTER_MESSAGE_ID" ); }); it("should not set enter message ID pref when empty string", () => { feed.inActivationWindowState = ""; feed.enterActivationWindowState("test-variant", true, true, ""); expect(calledNames(feed._prefs.set)).not.toContain( "activationWindow.enterMessageID" ); }); }); describe("#exitActivationWindowState", () => { beforeEach(() => { feed._prefs.isSet = jest.fn(); }); it("should broadcast pref changes when no user values were set", () => { feed._prefs.isSet.mockReturnValue(false); feed.exitActivationWindowState(); expect(feed.onPrefChanged).toHaveBeenCalledTimes(2); expect(feed.onPrefChanged).toHaveBeenCalledWith( "feeds.topsites", true, false ); expect(feed.onPrefChanged).toHaveBeenCalledWith( "feeds.section.topstories", true, false ); }); it("should only broadcast for top stories if top sites had user value", () => { feed._prefs.isSet.mockImplementation( name => name === "activationWindow.temp.topSitesUserValue" ); FAKE_PREFS.set("activationWindow.temp.topSitesUserValue", false); feed.exitActivationWindowState(); expect(feed.onPrefChanged).toHaveBeenCalledTimes(1); expect(feed.onPrefChanged).toHaveBeenCalledWith( "feeds.section.topstories", true, false ); expect(feed.onPrefChanged).not.toHaveBeenCalledWith( "feeds.topsites", true, false ); }); it("should only broadcast for top sites if top stories had user value", () => { feed._prefs.isSet.mockImplementation( name => name === "activationWindow.temp.topStoriesUserValue" ); FAKE_PREFS.set("activationWindow.temp.topStoriesUserValue", true); feed.exitActivationWindowState(); expect(feed.onPrefChanged).toHaveBeenCalledTimes(1); expect(feed.onPrefChanged).toHaveBeenCalledWith( "feeds.topsites", true, false ); expect(feed.onPrefChanged).not.toHaveBeenCalledWith( "feeds.section.topstories", true, false ); }); it("should not broadcast if both prefs had user values", () => { feed._prefs.isSet.mockReturnValue(true); FAKE_PREFS.set("activationWindow.temp.topSitesUserValue", false); FAKE_PREFS.set("activationWindow.temp.topStoriesUserValue", true); feed.exitActivationWindowState(); expect(feed.onPrefChanged).not.toHaveBeenCalled(); }); it("should clear enter message ID pref on exit", () => { feed._prefs.isSet.mockReturnValue(false); feed.exitActivationWindowState(); expect(feed._prefs.set).toHaveBeenCalledWith( "activationWindow.enterMessageID", "" ); }); it("should set exit message ID pref when provided", () => { feed._prefs.isSet.mockReturnValue(false); feed.exitActivationWindowState("EXIT_MESSAGE_ID"); expect(feed._prefs.set).toHaveBeenCalledWith( "activationWindow.exitMessageID", "EXIT_MESSAGE_ID" ); }); it("should clear exit message ID pref when not provided", () => { feed._prefs.isSet.mockReturnValue(false); feed.exitActivationWindowState(); expect(feed._prefs.set).toHaveBeenCalledWith( "activationWindow.exitMessageID", "" ); }); }); describe("store.dispatch integration", () => { beforeEach(() => { feed.inActivationWindowState = ""; FAKE_PREFS.set("feeds.topsites", { value: true }); FAKE_PREFS.set("feeds.section.topstories", { value: true }); }); it("should dispatch PREF_CHANGED actions when entering activation window", () => { feed.enterActivationWindowState(TEST_VARIANT, true, true); expect(feed.store.dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: at.PREF_CHANGED, data: { name: "feeds.topsites", value: false }, }) ); expect(feed.store.dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: at.PREF_CHANGED, data: { name: "feeds.section.topstories", value: false }, }) ); }); it("should dispatch PREF_CHANGED actions when exiting activation window", () => { feed._prefs.isSet = jest.fn(() => false); feed.exitActivationWindowState(); expect(feed.store.dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: at.PREF_CHANGED, data: { name: "feeds.topsites", value: true }, }) ); expect(feed.store.dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: at.PREF_CHANGED, data: { name: "feeds.section.topstories", value: true }, }) ); }); }); }); describe("Activation Window User Preference Tracking", () => { describe("#trackActivationWindowPrefChange", () => { it("should track top sites user value when changed during activation window", () => { feed.trackActivationWindowPrefChange("feeds.topsites", false); expect(feed._prefs.set).toHaveBeenCalledWith( "activationWindow.temp.topSitesUserValue", false ); }); it("should track top stories user value when changed during activation window", () => { feed.trackActivationWindowPrefChange("feeds.section.topstories", true); expect(feed._prefs.set).toHaveBeenCalledWith( "activationWindow.temp.topStoriesUserValue", true ); }); it("should not track changes for other prefs", () => { feed.trackActivationWindowPrefChange("some.other.pref", false); expect(calledNames(feed._prefs.set)).not.toContain( "activationWindow.temp.topSitesUserValue" ); expect(calledNames(feed._prefs.set)).not.toContain( "activationWindow.temp.topStoriesUserValue" ); }); }); describe("#onPrefChanged with activation window tracking", () => { beforeEach(() => { feed.inActivationWindowState = "variant-a"; jest.spyOn(feed, "trackActivationWindowPrefChange"); }); it("should call trackActivationWindowPrefChange when in activation window", () => { feed.onPrefChanged("feeds.topsites", false); expect(feed.trackActivationWindowPrefChange).toHaveBeenCalledTimes(1); expect(feed.trackActivationWindowPrefChange).toHaveBeenCalledWith( "feeds.topsites", false ); }); it("should not call trackActivationWindowPrefChange when not in activation window", () => { feed.inActivationWindowState = ""; feed.onPrefChanged("feeds.topsites", false); expect(feed.trackActivationWindowPrefChange).not.toHaveBeenCalled(); }); it("should not track when isUserChange=false even if in activation window", () => { feed.onPrefChanged("feeds.topsites", false, /* isUserChange */ false); expect(feed.trackActivationWindowPrefChange).not.toHaveBeenCalled(); }); it("should track when isUserChange=true (default) and in activation window", () => { feed.onPrefChanged("feeds.topsites", false, /* isUserChange */ true); expect(feed.trackActivationWindowPrefChange).toHaveBeenCalledTimes(1); }); it("should track when isUserChange not specified and in activation window", () => { feed.onPrefChanged("feeds.topsites", false); expect(feed.trackActivationWindowPrefChange).toHaveBeenCalledTimes(1); }); }); describe("#exitActivationWindowState", () => { let defaultBranch; beforeEach(() => { defaultBranch = { setBoolPref: jest.fn(), }; services.prefs.getDefaultBranch.mockReturnValue(defaultBranch); }); it("should reset defaults to true and restore user's top sites value", () => { feed._prefs.isSet = jest.fn( name => name === "activationWindow.temp.topSitesUserValue" ); FAKE_PREFS.set("activationWindow.temp.topSitesUserValue", false); feed.exitActivationWindowState(); expect(defaultBranch.setBoolPref).toHaveBeenCalledWith( "feeds.topsites", true ); expect(defaultBranch.setBoolPref).toHaveBeenCalledWith( "feeds.section.topstories", true ); expect(feed._prefs.set).toHaveBeenCalledWith("feeds.topsites", false); expect(feed._prefs.reset).toHaveBeenCalledWith( "activationWindow.temp.topSitesUserValue" ); }); it("should reset defaults to true and restore user's top stories value", () => { feed._prefs.isSet = jest.fn( name => name === "activationWindow.temp.topStoriesUserValue" ); FAKE_PREFS.set("activationWindow.temp.topStoriesUserValue", true); feed.exitActivationWindowState(); expect(defaultBranch.setBoolPref).toHaveBeenCalledWith( "feeds.topsites", true ); expect(defaultBranch.setBoolPref).toHaveBeenCalledWith( "feeds.section.topstories", true ); expect(feed._prefs.set).toHaveBeenCalledWith( "feeds.section.topstories", true ); expect(feed._prefs.reset).toHaveBeenCalledWith( "activationWindow.temp.topStoriesUserValue" ); }); it("should only reset defaults when no user changes were made", () => { feed._prefs.isSet = jest.fn(() => false); feed.exitActivationWindowState(); expect(defaultBranch.setBoolPref).toHaveBeenCalledWith( "feeds.topsites", true ); expect(defaultBranch.setBoolPref).toHaveBeenCalledWith( "feeds.section.topstories", true ); expect(calledNames(feed._prefs.set)).not.toContain("feeds.topsites"); expect(calledNames(feed._prefs.set)).not.toContain( "feeds.section.topstories" ); }); it("should clear activation window variant pref", () => { feed._prefs.isSet = jest.fn(() => false); feed.exitActivationWindowState(); expect(feed._prefs.reset).toHaveBeenCalledWith( "activationWindow.variant" ); }); it("should handle user disabling top sites during activation window", () => { feed._prefs.isSet = jest.fn( name => name === "activationWindow.temp.topSitesUserValue" ); FAKE_PREFS.set("activationWindow.temp.topSitesUserValue", false); feed.exitActivationWindowState(); expect(defaultBranch.setBoolPref).toHaveBeenCalledWith( "feeds.topsites", true ); expect(feed._prefs.set).toHaveBeenCalledWith("feeds.topsites", false); }); it("should handle user enabling top sites during activation window", () => { feed._prefs.isSet = jest.fn( name => name === "activationWindow.temp.topSitesUserValue" ); FAKE_PREFS.set("activationWindow.temp.topSitesUserValue", true); feed.exitActivationWindowState(); expect(defaultBranch.setBoolPref).toHaveBeenCalledWith( "feeds.topsites", true ); expect(feed._prefs.set).toHaveBeenCalledWith("feeds.topsites", true); }); }); }); });