From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../newtab/test/unit/asrouter/ASRouter.test.js | 3040 ++++++++++++++++++++ 1 file changed, 3040 insertions(+) create mode 100644 browser/components/newtab/test/unit/asrouter/ASRouter.test.js (limited to 'browser/components/newtab/test/unit/asrouter/ASRouter.test.js') diff --git a/browser/components/newtab/test/unit/asrouter/ASRouter.test.js b/browser/components/newtab/test/unit/asrouter/ASRouter.test.js new file mode 100644 index 0000000000..732200b408 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouter.test.js @@ -0,0 +1,3040 @@ +import { _ASRouter, MessageLoaderUtils } from "lib/ASRouter.jsm"; +import { QueryCache } from "lib/ASRouterTargeting.jsm"; +import { + FAKE_LOCAL_MESSAGES, + FAKE_LOCAL_PROVIDER, + FAKE_LOCAL_PROVIDERS, + FAKE_REMOTE_MESSAGES, + FAKE_REMOTE_PROVIDER, + FAKE_REMOTE_SETTINGS_PROVIDER, +} from "./constants"; +import { + ASRouterPreferences, + TARGETING_PREFERENCES, +} from "lib/ASRouterPreferences.jsm"; +import { ASRouterTriggerListeners } from "lib/ASRouterTriggerListeners.jsm"; +import { CFRPageActions } from "lib/CFRPageActions.jsm"; +import { GlobalOverrider } from "test/unit/utils"; +import { PanelTestProvider } from "lib/PanelTestProvider.sys.mjs"; +import ProviderResponseSchema from "content-src/asrouter/schemas/provider-response.schema.json"; +import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.sys.mjs"; + +const MESSAGE_PROVIDER_PREF_NAME = + "browser.newtabpage.activity-stream.asrouter.providers.snippets"; +const FAKE_PROVIDERS = [ + FAKE_LOCAL_PROVIDER, + FAKE_REMOTE_PROVIDER, + FAKE_REMOTE_SETTINGS_PROVIDER, +]; +const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; +const FAKE_RESPONSE_HEADERS = { get() {} }; +const FAKE_BUNDLE = [FAKE_LOCAL_MESSAGES[1], FAKE_LOCAL_MESSAGES[2]]; + +const USE_REMOTE_L10N_PREF = + "browser.newtabpage.activity-stream.asrouter.useRemoteL10n"; + +// eslint-disable-next-line max-statements +describe("ASRouter", () => { + let Router; + let globals; + let sandbox; + let initParams; + let messageBlockList; + let providerBlockList; + let messageImpressions; + let groupImpressions; + let previousSessionEnd; + let fetchStub; + let clock; + let getStringPrefStub; + let fakeAttributionCode; + let fakeTargetingContext; + let FakeToolbarBadgeHub; + let FakeToolbarPanelHub; + let FakeMomentsPageHub; + let ASRouterTargeting; + let screenImpressions; + + function setMessageProviderPref(value) { + sandbox.stub(ASRouterPreferences, "providers").get(() => value); + } + + function initASRouter(router) { + const getStub = sandbox.stub(); + getStub.returns(Promise.resolve()); + getStub + .withArgs("messageBlockList") + .returns(Promise.resolve(messageBlockList)); + getStub + .withArgs("providerBlockList") + .returns(Promise.resolve(providerBlockList)); + getStub + .withArgs("messageImpressions") + .returns(Promise.resolve(messageImpressions)); + getStub.withArgs("groupImpressions").resolves(groupImpressions); + getStub + .withArgs("previousSessionEnd") + .returns(Promise.resolve(previousSessionEnd)); + getStub + .withArgs("screenImpressions") + .returns(Promise.resolve(screenImpressions)); + initParams = { + storage: { + get: getStub, + set: sandbox.stub().returns(Promise.resolve()), + }, + sendTelemetry: sandbox.stub().resolves(), + clearChildMessages: sandbox.stub().resolves(), + clearChildProviders: sandbox.stub().resolves(), + updateAdminState: sandbox.stub().resolves(), + dispatchCFRAction: sandbox.stub().resolves(), + }; + sandbox.stub(router, "loadMessagesFromAllProviders").callThrough(); + return router.init(initParams); + } + + async function createRouterAndInit(providers = FAKE_PROVIDERS) { + setMessageProviderPref(providers); + // `.freeze` to catch any attempts at modifying the object + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + await initASRouter(Router); + } + + beforeEach(async () => { + globals = new GlobalOverrider(); + messageBlockList = []; + providerBlockList = []; + messageImpressions = {}; + groupImpressions = {}; + previousSessionEnd = 100; + screenImpressions = {}; + sandbox = sinon.createSandbox(); + ASRouterTargeting = { + isMatch: sandbox.stub(), + findMatchingMessage: sandbox.stub(), + Environment: { + locale: "en-US", + localeLanguageCode: "en", + browserSettings: { + update: { + channel: "default", + enabled: true, + autoDownload: true, + }, + }, + attributionData: {}, + currentDate: "2000-01-01T10:00:0.001Z", + profileAgeCreated: {}, + profileAgeReset: {}, + usesFirefoxSync: false, + isFxAEnabled: true, + isFxASignedIn: false, + sync: { + desktopDevices: 0, + mobileDevices: 0, + totalDevices: 0, + }, + xpinstallEnabled: true, + addonsInfo: {}, + searchEngines: {}, + isDefaultBrowser: false, + devToolsOpenedCount: 5, + topFrecentSites: {}, + recentBookmarks: {}, + pinnedSites: [ + { + url: "https://amazon.com", + host: "amazon.com", + searchTopSite: true, + }, + ], + providerCohorts: { + onboarding: "", + cfr: "", + "message-groups": "", + "messaging-experiments": "", + snippets: "", + "whats-new-panel": "", + }, + totalBookmarksCount: {}, + firefoxVersion: 80, + region: "US", + needsUpdate: {}, + hasPinnedTabs: false, + hasAccessedFxAPanel: false, + isWhatsNewPanelEnabled: true, + userPrefs: { + cfrFeatures: true, + cfrAddons: true, + snippets: true, + }, + totalBlockedCount: {}, + blockedCountByType: {}, + attachedFxAOAuthClients: [], + platformName: "macosx", + scores: {}, + scoreThreshold: 5000, + isChinaRepack: false, + userId: "adsf", + }, + }; + + ASRouterPreferences.specialConditions = { + someCondition: true, + }; + sandbox.spy(ASRouterPreferences, "init"); + sandbox.spy(ASRouterPreferences, "uninit"); + sandbox.spy(ASRouterPreferences, "addListener"); + sandbox.spy(ASRouterPreferences, "removeListener"); + + clock = sandbox.useFakeTimers(); + fetchStub = sandbox + .stub(global, "fetch") + .withArgs("http://fake.com/endpoint") + .resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({ messages: FAKE_REMOTE_MESSAGES }), + headers: FAKE_RESPONSE_HEADERS, + }); + getStringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref"); + + fakeAttributionCode = { + allowedCodeKeys: ["foo", "bar", "baz"], + _clearCache: () => sinon.stub(), + getAttrDataAsync: () => Promise.resolve({ content: "addonID" }), + deleteFileAsync: () => Promise.resolve(), + writeAttributionFile: () => Promise.resolve(), + getCachedAttributionData: sinon.stub(), + }; + FakeToolbarPanelHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + forceShowMessage: sandbox.stub(), + enableToolbarButton: sandbox.stub(), + }; + FakeToolbarBadgeHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + registerBadgeNotificationListener: sandbox.stub(), + }; + FakeMomentsPageHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + executeAction: sandbox.stub(), + }; + fakeTargetingContext = { + combineContexts: sandbox.stub(), + evalWithDefault: sandbox.stub().resolves(), + }; + let fakeNimbusFeatures = [ + "cfr", + "infobar", + "spotlight", + "moments-page", + "pbNewtab", + ].reduce((features, featureId) => { + features[featureId] = { + getAllVariables: sandbox.stub().returns(null), + recordExposureEvent: sandbox.stub(), + }; + return features; + }, {}); + globals.set({ + // Testing framework doesn't know how to `defineLazyModuleGetter` so we're + // importing these modules into the global scope ourselves. + GroupsConfigurationProvider: { getMessages: () => Promise.resolve([]) }, + ASRouterPreferences, + TARGETING_PREFERENCES, + ASRouterTargeting, + ASRouterTriggerListeners, + QueryCache, + gBrowser: { selectedBrowser: {} }, + gURLBar: {}, + isSeparateAboutWelcome: true, + AttributionCode: fakeAttributionCode, + SnippetsTestMessageProvider, + PanelTestProvider, + MacAttribution: { applicationPath: "" }, + ToolbarBadgeHub: FakeToolbarBadgeHub, + ToolbarPanelHub: FakeToolbarPanelHub, + MomentsPageHub: FakeMomentsPageHub, + KintoHttpClient: class { + bucket() { + return this; + } + collection() { + return this; + } + getRecord() { + return Promise.resolve({ data: {} }); + } + }, + Downloader: class { + download() { + return Promise.resolve("/path/to/download"); + } + }, + NimbusFeatures: fakeNimbusFeatures, + ExperimentAPI: { + getExperimentMetaData: sandbox.stub().returns({ + slug: "experiment-slug", + active: true, + branch: { slug: "experiment-branch-slug" }, + }), + getExperiment: sandbox.stub().returns({ + branch: { + slug: "unit-slug", + feature: { + featureId: "foo", + value: { id: "test-message" }, + }, + }, + }), + getAllBranches: sandbox.stub().resolves([]), + ready: sandbox.stub().resolves(), + }, + SpecialMessageActions: { + handleAction: sandbox.stub(), + }, + TargetingContext: class { + static combineContexts(...args) { + return fakeTargetingContext.combineContexts.apply(sandbox, args); + } + + evalWithDefault(expr) { + return fakeTargetingContext.evalWithDefault(expr); + } + }, + RemoteL10n: { + // This is just a subset of supported locales that happen to be used in + // the test. + isLocaleSupported: locale => ["en-US", "ja-JP-mac"].includes(locale), + }, + }); + await createRouterAndInit(); + }); + afterEach(() => { + Router.uninit(); + ASRouterPreferences.uninit(); + sandbox.restore(); + globals.restore(); + }); + + describe(".state", () => { + it("should throw if an attempt to set .state was made", () => { + assert.throws(() => { + Router.state = {}; + }); + }); + }); + + describe("#init", () => { + it("should only be called once", async () => { + Router = new _ASRouter(); + let state = await initASRouter(Router); + + assert.equal(state, Router.state); + + assert.isNull(await Router.init({})); + }); + it("should only be called once", async () => { + Router = new _ASRouter(); + initASRouter(Router); + let secondCall = await Router.init({}); + + assert.isNull( + secondCall, + "Should not init twice, it should exit early with null" + ); + }); + it("should set state.messageBlockList to the block list in persistent storage", async () => { + messageBlockList = ["foo"]; + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.messageBlockList, ["foo"]); + }); + it("should initialize all the hub providers", async () => { + // ASRouter init called in `beforeEach` block above + + assert.calledOnce(FakeToolbarBadgeHub.init); + assert.calledOnce(FakeToolbarPanelHub.init); + assert.calledOnce(FakeMomentsPageHub.init); + + assert.calledWithExactly( + FakeToolbarBadgeHub.init, + Router.waitForInitialized, + { + handleMessageRequest: Router.handleMessageRequest, + addImpression: Router.addImpression, + blockMessageById: Router.blockMessageById, + sendTelemetry: Router.sendTelemetry, + unblockMessageById: Router.unblockMessageById, + } + ); + + assert.calledWithExactly( + FakeToolbarPanelHub.init, + Router.waitForInitialized, + { + getMessages: Router.handleMessageRequest, + sendTelemetry: Router.sendTelemetry, + } + ); + + assert.calledWithExactly( + FakeMomentsPageHub.init, + Router.waitForInitialized, + { + handleMessageRequest: Router.handleMessageRequest, + addImpression: Router.addImpression, + blockMessageById: Router.blockMessageById, + sendTelemetry: Router.sendTelemetry, + } + ); + }); + it("should set state.messageImpressions to the messageImpressions object in persistent storage", async () => { + // Note that messageImpressions are only kept if a message exists in router and has a .frequency property, + // otherwise they will be cleaned up by .cleanupImpressions() + const testMessage = { id: "foo", frequency: { lifetimeCap: 10 } }; + messageImpressions = { foo: [0, 1, 2] }; + setMessageProviderPref([ + { id: "onboarding", type: "local", messages: [testMessage] }, + ]); + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.messageImpressions, messageImpressions); + }); + it("should set state.screenImpressions to the screenImpressions object in persistent storage", async () => { + screenImpressions = { test: 123 }; + + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.screenImpressions, screenImpressions); + }); + it("should clear impressions for groups that are not active", async () => { + groupImpressions = { foo: [0, 1, 2] }; + Router = new _ASRouter(); + await initASRouter(Router); + + assert.notProperty(Router.state.groupImpressions, "foo"); + }); + it("should keep impressions for groups that are active", async () => { + Router = new _ASRouter(); + await initASRouter(Router); + await Router.setState(() => { + return { + groups: [ + { + id: "foo", + enabled: true, + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 10 }], + lifetime: Infinity, + }, + }, + ], + groupImpressions: { foo: [Date.now()] }, + }; + }); + Router.cleanupImpressions(); + + assert.property(Router.state.groupImpressions, "foo"); + assert.lengthOf(Router.state.groupImpressions.foo, 1); + }); + it("should remove old impressions for a group", async () => { + Router = new _ASRouter(); + await initASRouter(Router); + await Router.setState(() => { + return { + groups: [ + { + id: "foo", + enabled: true, + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 10 }], + }, + }, + ], + groupImpressions: { + foo: [Date.now() - ONE_DAY_IN_MS - 1, Date.now()], + }, + }; + }); + Router.cleanupImpressions(); + + assert.property(Router.state.groupImpressions, "foo"); + assert.lengthOf(Router.state.groupImpressions.foo, 1); + }); + it("should await .loadMessagesFromAllProviders() and add messages from providers to state.messages", async () => { + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + + await initASRouter(Router); + + assert.calledOnce(Router.loadMessagesFromAllProviders); + assert.isArray(Router.state.messages); + assert.lengthOf( + Router.state.messages, + FAKE_LOCAL_MESSAGES.length + FAKE_REMOTE_MESSAGES.length + ); + }); + it("should load additional allowed hosts", async () => { + getStringPrefStub.returns('["allow.com"]'); + await createRouterAndInit(); + + assert.propertyVal(Router.ALLOWLIST_HOSTS, "allow.com", "preview"); + // Should still include the defaults + assert.lengthOf(Object.keys(Router.ALLOWLIST_HOSTS), 3); + }); + it("should fallback to defaults if pref parsing fails", async () => { + getStringPrefStub.returns("err"); + await createRouterAndInit(); + + assert.lengthOf(Object.keys(Router.ALLOWLIST_HOSTS), 2); + assert.propertyVal( + Router.ALLOWLIST_HOSTS, + "snippets-admin.mozilla.org", + "preview" + ); + assert.propertyVal( + Router.ALLOWLIST_HOSTS, + "activity-stream-icons.services.mozilla.com", + "production" + ); + }); + it("should set state.previousSessionEnd from IndexedDB", async () => { + previousSessionEnd = 200; + await createRouterAndInit(); + + assert.equal(Router.state.previousSessionEnd, previousSessionEnd); + }); + it("should assign ASRouterPreferences.specialConditions to state", async () => { + assert.isTrue(ASRouterPreferences.specialConditions.someCondition); + assert.isTrue(Router.state.someCondition); + }); + it("should add observer for `intl:app-locales-changed`", async () => { + sandbox.spy(global.Services.obs, "addObserver"); + await createRouterAndInit(); + + assert.calledWithExactly( + global.Services.obs.addObserver, + Router._onLocaleChanged, + "intl:app-locales-changed" + ); + }); + it("should add a pref observer", async () => { + sandbox.spy(global.Services.prefs, "addObserver"); + await createRouterAndInit(); + + assert.calledOnce(global.Services.prefs.addObserver); + assert.calledWithExactly( + global.Services.prefs.addObserver, + USE_REMOTE_L10N_PREF, + Router + ); + }); + describe("lazily loading local test providers", () => { + afterEach(() => { + Router.uninit(); + }); + it("should add the local test providers on init if devtools are enabled", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true); + + await createRouterAndInit(); + + assert.property(Router._localProviders, "SnippetsTestMessageProvider"); + assert.property(Router._localProviders, "PanelTestProvider"); + }); + it("should not add the local test providers on init if devtools are disabled", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false); + + await createRouterAndInit(); + + assert.notProperty( + Router._localProviders, + "SnippetsTestMessageProvider" + ); + assert.notProperty(Router._localProviders, "PanelTestProvider"); + }); + }); + }); + + describe("preference changes", () => { + it("should call ASRouterPreferences.init and add a listener on init", () => { + assert.calledOnce(ASRouterPreferences.init); + assert.calledWith(ASRouterPreferences.addListener, Router.onPrefChange); + }); + it("should call ASRouterPreferences.uninit and remove the listener on uninit", () => { + Router.uninit(); + assert.calledOnce(ASRouterPreferences.uninit); + assert.calledWith( + ASRouterPreferences.removeListener, + Router.onPrefChange + ); + }); + it("should send a AS_ROUTER_TARGETING_UPDATE message", async () => { + const messageTargeted = { + id: "1", + campaign: "foocampaign", + targeting: "true", + groups: ["snippets"], + provider: "snippets", + }; + const messageNotTargeted = { + id: "2", + campaign: "foocampaign", + groups: ["snippets"], + provider: "snippets", + }; + await Router.setState({ + messages: [messageTargeted, messageNotTargeted], + providers: [{ id: "snippets" }], + }); + fakeTargetingContext.evalWithDefault.resolves(false); + + await Router.onPrefChange("services.sync.username"); + + assert.calledOnce(initParams.clearChildMessages); + assert.calledWith(initParams.clearChildMessages, [messageTargeted.id]); + }); + it("should call loadMessagesFromAllProviders on pref change", () => { + ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME); + assert.calledOnce(Router.loadMessagesFromAllProviders); + }); + it("should update groups state if a user pref changes", async () => { + await Router.setState({ + groups: [{ id: "foo", userPreferences: ["bar"], enabled: true }], + }); + sandbox.stub(ASRouterPreferences, "getUserPreference"); + + await Router.onPrefChange(MESSAGE_PROVIDER_PREF_NAME); + + assert.calledWithExactly(ASRouterPreferences.getUserPreference, "bar"); + }); + it("should update the list of providers on pref change", async () => { + const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, { + url: "baz.com", + }); + setMessageProviderPref([ + FAKE_LOCAL_PROVIDER, + modifiedRemoteProvider, + FAKE_REMOTE_SETTINGS_PROVIDER, + ]); + + const { length } = Router.state.providers; + + ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME); + await Router._updateMessageProviders(); + + const provider = Router.state.providers.find(p => p.url === "baz.com"); + assert.lengthOf(Router.state.providers, length); + assert.isDefined(provider); + }); + it("should clear disabled providers on pref change", async () => { + const TEST_PROVIDER_ID = "some_provider_id"; + await Router.setState({ + providers: [{ id: TEST_PROVIDER_ID }], + }); + const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, { + id: TEST_PROVIDER_ID, + enabled: false, + }); + setMessageProviderPref([ + FAKE_LOCAL_PROVIDER, + modifiedRemoteProvider, + FAKE_REMOTE_SETTINGS_PROVIDER, + ]); + await Router.onPrefChange(MESSAGE_PROVIDER_PREF_NAME); + + assert.calledOnce(initParams.clearChildProviders); + assert.calledWith(initParams.clearChildProviders, [TEST_PROVIDER_ID]); + }); + }); + + describe("setState", () => { + it("should broadcast a message to update the admin tool on a state change if the asrouter.devtoolsEnabled pref is", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true); + sandbox.stub(Router, "getTargetingParameters").resolves({}); + const state = await Router.setState({ foo: 123 }); + + assert.calledOnce(initParams.updateAdminState); + assert.deepEqual(state.providerPrefs, ASRouterPreferences.providers); + assert.deepEqual( + state.userPrefs, + ASRouterPreferences.getAllUserPreferences() + ); + assert.deepEqual(state.targetingParameters, {}); + assert.deepEqual(state.errors, Router.errors); + }); + it("should not send a message on a state change asrouter.devtoolsEnabled pref is on", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false); + await Router.setState({ foo: 123 }); + + assert.notCalled(initParams.updateAdminState); + }); + }); + + describe("getTargetingParameters", () => { + it("should return the targeting parameters", async () => { + const stub = sandbox.stub().resolves("foo"); + const obj = { foo: 1 }; + sandbox.stub(obj, "foo").get(stub); + const result = await Router.getTargetingParameters(obj, obj); + + assert.calledTwice(stub); + assert.propertyVal(result, "foo", "foo"); + }); + }); + + describe("evaluateExpression", () => { + it("should call ASRouterTargeting to evaluate", async () => { + fakeTargetingContext.evalWithDefault.resolves("foo"); + const response = await Router.evaluateExpression({}); + assert.equal(response.evaluationStatus.result, "foo"); + assert.isTrue(response.evaluationStatus.success); + }); + it("should catch evaluation errors", async () => { + fakeTargetingContext.evalWithDefault.returns( + Promise.reject(new Error("fake error")) + ); + const response = await Router.evaluateExpression({}); + assert.isFalse(response.evaluationStatus.success); + }); + }); + + describe("#routeCFRMessage", () => { + let browser; + beforeEach(() => { + sandbox.stub(CFRPageActions, "forceRecommendation"); + sandbox.stub(CFRPageActions, "addRecommendation"); + browser = {}; + }); + it("should route whatsnew_panel_message message to the right hub", () => { + Router.routeCFRMessage( + { template: "whatsnew_panel_message" }, + browser, + "", + true + ); + + assert.calledOnce(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route moments messages to the right hub", () => { + Router.routeCFRMessage({ template: "update_action" }, browser, "", true); + + assert.calledOnce(FakeMomentsPageHub.executeAction); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + }); + it("should route toolbar_badge message to the right hub", () => { + Router.routeCFRMessage({ template: "toolbar_badge" }, browser); + + assert.calledOnce(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route milestone_message to the right hub", () => { + Router.routeCFRMessage( + { template: "milestone_message" }, + browser, + "", + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_doorhanger message to the right hub force = false", () => { + Router.routeCFRMessage( + { template: "cfr_doorhanger" }, + browser, + { param: {} }, + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_doorhanger message to the right hub force = true", () => { + Router.routeCFRMessage({ template: "cfr_doorhanger" }, browser, {}, true); + + assert.calledOnce(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_urlbar_chiclet message to the right hub force = false", () => { + Router.routeCFRMessage( + { template: "cfr_urlbar_chiclet" }, + browser, + { param: {} }, + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + const { args } = CFRPageActions.addRecommendation.firstCall; + // Host should be null + assert.isNull(args[1]); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_urlbar_chiclet message to the right hub force = true", () => { + Router.routeCFRMessage( + { template: "cfr_urlbar_chiclet" }, + browser, + {}, + true + ); + + assert.calledOnce(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route default to sending to content", () => { + Router.routeCFRMessage({ template: "snippets" }, browser, {}, true); + + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + }); + + describe("#loadMessagesFromAllProviders", () => { + function assertRouterContainsMessages(messages) { + const messageIdsInRouter = Router.state.messages.map(m => m.id); + for (const message of messages) { + assert.include(messageIdsInRouter, message.id); + } + } + + it("should not trigger an update if not enough time has passed for a provider", async () => { + await createRouterAndInit([ + { + id: "remotey", + type: "remote", + enabled: true, + url: "http://fake.com/endpoint", + updateCycleInMs: 300, + }, + ]); + + const previousState = Router.state; + + // Since we've previously gotten messages during init and we haven't advanced our fake timer, + // no updates should be triggered. + await Router.loadMessagesFromAllProviders(); + assert.deepEqual(Router.state, previousState); + }); + it("should not trigger an update if we only have local providers", async () => { + await createRouterAndInit([ + { + id: "foo", + type: "local", + enabled: true, + messages: FAKE_LOCAL_MESSAGES, + }, + ]); + + const previousState = Router.state; + const stub = sandbox.stub(MessageLoaderUtils, "loadMessagesForProvider"); + + clock.tick(300); + + await Router.loadMessagesFromAllProviders(); + + assert.deepEqual(Router.state, previousState); + assert.notCalled(stub); + }); + it("should update messages for a provider if enough time has passed, without removing messages for other providers", async () => { + const NEW_MESSAGES = [{ id: "new_123" }]; + await createRouterAndInit([ + { + id: "remotey", + type: "remote", + url: "http://fake.com/endpoint", + enabled: true, + updateCycleInMs: 300, + }, + { + id: "alocalprovider", + type: "local", + enabled: true, + messages: FAKE_LOCAL_MESSAGES, + }, + ]); + fetchStub.withArgs("http://fake.com/endpoint").resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({ messages: NEW_MESSAGES }), + headers: FAKE_RESPONSE_HEADERS, + }); + + clock.tick(301); + await Router.loadMessagesFromAllProviders(); + + // These are the new messages + assertRouterContainsMessages(NEW_MESSAGES); + // These are the local messages that should not have been deleted + assertRouterContainsMessages(FAKE_LOCAL_MESSAGES); + }); + it("should parse the triggers in the messages and register the trigger listeners", async () => { + sandbox.spy( + ASRouterTriggerListeners.get("openURL"), + "init" + ); /* eslint-disable object-property-newline */ + + /* eslint-disable object-curly-newline */ await createRouterAndInit([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + { + id: "foo", + template: "simple_template", + trigger: { id: "firstRun" }, + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "bar1", + template: "simple_template", + trigger: { + id: "openURL", + params: ["www.mozilla.org", "www.mozilla.com"], + }, + content: { title: "Bar1", body: "Bar123" }, + }, + { + id: "bar2", + template: "simple_template", + trigger: { id: "openURL", params: ["www.example.com"] }, + content: { title: "Bar2", body: "Bar123" }, + }, + ], + }, + ]); /* eslint-enable object-property-newline */ + /* eslint-enable object-curly-newline */ assert.calledTwice( + ASRouterTriggerListeners.get("openURL").init + ); + assert.calledWithExactly( + ASRouterTriggerListeners.get("openURL").init, + Router._triggerHandler, + ["www.mozilla.org", "www.mozilla.com"], + undefined + ); + assert.calledWithExactly( + ASRouterTriggerListeners.get("openURL").init, + Router._triggerHandler, + ["www.example.com"], + undefined + ); + }); + it("should parse the message's messagesLoaded trigger and immediately fire trigger", async () => { + setMessageProviderPref([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + { + id: "bar3", + template: "simple_template", + trigger: { id: "messagesLoaded" }, + content: { title: "Bar3", body: "Bar123" }, + }, + ], + }, + ]); + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + sandbox.spy(Router, "sendTriggerMessage"); + await initASRouter(Router); + assert.calledOnce(Router.sendTriggerMessage); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ id: "messagesLoaded" }), + true + ); + }); + it("should gracefully handle messages loading before a window or browser exists", async () => { + sandbox.stub(global, "gBrowser").value(undefined); + sandbox + .stub(global.Services.wm, "getMostRecentBrowserWindow") + .returns(undefined); + setMessageProviderPref([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + "whatsnew_panel_message", + "cfr_doorhanger", + "toolbar_badge", + "update_action", + "infobar", + "spotlight", + "toast_notification", + ].map((template, i) => { + return { + id: `foo${i}`, + template, + trigger: { id: "messagesLoaded" }, + content: { title: `Foo${i}`, body: "Bar123" }, + }; + }), + }, + ]); + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + sandbox.spy(Router, "sendTriggerMessage"); + await initASRouter(Router); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ id: "messagesLoaded" }), + true + ); + }); + it("should gracefully handle RemoteSettings blowing up and dispatch undesired event", async () => { + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .rejects("fake error"); + await createRouterAndInit(); + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_ERROR", + event_context: "remotey-settingsy", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + it("should dispatch undesired event if RemoteSettings returns no messages", async () => { + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([]); + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_NO_MESSAGES", + event_context: "remotey-settingsy", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + it("should download the attachment if RemoteSettings returns some messages", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + const spy = sandbox.spy(); + global.Downloader.prototype.downloadToDisk = spy; + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + + assert.calledOnce(spy); + }); + it("should dispatch undesired event if the ms-language-packs returns no messages", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + sandbox + .stub(global.KintoHttpClient.prototype, "getRecord") + .resolves(null); + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_NO_MESSAGES", + event_context: "ms-language-packs", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + }); + + describe("#_updateMessageProviders", () => { + it("should correctly replace %STARTPAGE_VERSION% in remote provider urls", async () => { + // If this test fails, you need to update the constant STARTPAGE_VERSION in + // ASRouter.jsm to match the `version` property of provider-response-schema.json + const expectedStartpageVersion = ProviderResponseSchema.version; + const provider = { + id: "foo", + enabled: true, + type: "remote", + url: "https://www.mozilla.org/%STARTPAGE_VERSION%/", + }; + setMessageProviderPref([provider]); + await Router._updateMessageProviders(); + assert.equal( + Router.state.providers[0].url, + `https://www.mozilla.org/${parseInt(expectedStartpageVersion, 10)}/` + ); + }); + it("should replace other params in remote provider urls by calling Services.urlFormater.formatURL", async () => { + const url = "https://www.example.com/"; + const replacedUrl = "https://www.foo.bar/"; + const stub = sandbox + .stub(global.Services.urlFormatter, "formatURL") + .withArgs(url) + .returns(replacedUrl); + const provider = { id: "foo", enabled: true, type: "remote", url }; + setMessageProviderPref([provider]); + await Router._updateMessageProviders(); + assert.calledOnce(stub); + assert.calledWithExactly(stub, url); + assert.equal(Router.state.providers[0].url, replacedUrl); + }); + it("should only add the providers that are enabled", async () => { + const providers = [ + { + id: "foo", + enabled: false, + type: "remote", + url: "https://www.foo.com/", + }, + { + id: "bar", + enabled: true, + type: "remote", + url: "https://www.bar.com/", + }, + ]; + setMessageProviderPref(providers); + await Router._updateMessageProviders(); + assert.equal(Router.state.providers.length, 1); + assert.equal(Router.state.providers[0].id, providers[1].id); + }); + }); + + describe("#handleMessageRequest", () => { + beforeEach(async () => { + await Router.setState(() => ({ + providers: [{ id: "snippets" }, { id: "badge" }], + })); + }); + it("should not return a blocked message", async () => { + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { id: "foo", provider: "snippets", groups: ["snippets"] }, + { id: "bar", provider: "snippets", groups: ["snippets"] }, + ], + messageBlockList: ["foo"], + })); + await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, { + messages: [{ id: "bar", provider: "snippets", groups: ["snippets"] }], + }); + }); + it("should not return a message from a disabled group", async () => { + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { id: "foo", provider: "snippets", groups: ["snippets"] }, + { id: "bar", provider: "snippets", groups: ["snippets"] }, + ], + groups: [{ id: "snippets", enabled: false }], + })); + const result = await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.isNull(result); + }); + it("should not return a message from a blocked campaign", async () => { + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { + id: "foo", + provider: "snippets", + campaign: "foocampaign", + groups: ["snippets"], + }, + { id: "bar", provider: "snippets", groups: ["snippets"] }, + ], + messageBlockList: ["foocampaign"], + })); + + await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, { + messages: [{ id: "bar", provider: "snippets", groups: ["snippets"] }], + }); + }); + it("should not return a message excluded by the provider", async () => { + // There are only two providers; block the FAKE_LOCAL_PROVIDER, leaving + // only FAKE_REMOTE_PROVIDER unblocked, which provides only one message + await Router.setState(() => ({ + providers: [{ id: "snippets", exclude: ["foo"] }], + })); + + await Router.setState(() => ({ + messages: [{ id: "foo", provider: "snippets" }], + messageBlockList: ["foocampaign"], + })); + + const result = await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.isNull(result); + }); + it("should not return a message if the frequency cap has been hit", async () => { + sandbox.stub(Router, "isBelowFrequencyCaps").returns(false); + await Router.setState(() => ({ + messages: [{ id: "foo", provider: "snippets" }], + })); + const result = await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.isNull(result); + }); + it("should get unblocked messages that match the trigger", async () => { + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + provider: "snippets", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["snippets"], + provider: "snippets", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + + const result = Router.handleMessageRequest({ triggerId: "foo" }); + + assert.deepEqual(result, message1); + }); + it("should get unblocked messages that match trigger and template", async () => { + const message1 = { + id: "1", + campaign: "foocampaign", + template: "badge", + trigger: { id: "foo" }, + groups: ["badge"], + provider: "badge", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + template: "snippet", + trigger: { id: "foo" }, + groups: ["snippets"], + provider: "snippets", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + + const result = Router.handleMessageRequest({ + triggerId: "foo", + template: "badge", + }); + + assert.deepEqual(result, message1); + }); + it("should have messageImpressions in the message context", () => { + assert.propertyVal( + Router._getMessagesContext(), + "messageImpressions", + Router.state.messageImpressions + ); + }); + it("should return all unblocked messages that match the template, trigger if returnAll=true", async () => { + const message1 = { + provider: "whats_new", + id: "1", + template: "whatsnew_panel_message", + trigger: { id: "whatsNewPanelOpened" }, + groups: ["whats_new"], + }; + const message2 = { + provider: "whats_new", + id: "2", + template: "whatsnew_panel_message", + trigger: { id: "whatsNewPanelOpened" }, + groups: ["whats_new"], + }; + const message3 = { + provider: "whats_new", + id: "3", + template: "badge", + groups: ["whats_new"], + }; + ASRouterTargeting.findMatchingMessage.callsFake(() => [ + message2, + message1, + ]); + await Router.setState({ + messages: [message3, message2, message1], + providers: [{ id: "whats_new" }], + }); + const result = await Router.handleMessageRequest({ + template: "whatsnew_panel_message", + triggerId: "whatsNewPanelOpened", + returnAll: true, + }); + + assert.deepEqual(result, [message2, message1]); + }); + it("should forward trigger param info", async () => { + const trigger = { + triggerId: "foo", + triggerParam: "bar", + triggerContext: "context", + }; + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + provider: "snippets", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["badge"], + provider: "badge", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + + Router.handleMessageRequest(trigger); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + + const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args; + assert.propertyVal(options.trigger, "id", trigger.triggerId); + assert.propertyVal(options.trigger, "param", trigger.triggerParam); + assert.propertyVal(options.trigger, "context", trigger.triggerContext); + }); + it("should cache snippets messages", async () => { + const trigger = { + triggerId: "foo", + triggerParam: "bar", + triggerContext: "context", + }; + const message1 = { + id: "1", + provider: "snippets", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["snippets"], + }; + await Router.setState({ messages: [message2, message1] }); + + Router.handleMessageRequest(trigger); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + + const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args; + assert.propertyVal(options, "shouldCache", true); + }); + it("should not cache badge messages", async () => { + const trigger = { + triggerId: "bar", + triggerParam: "bar", + triggerContext: "context", + }; + const message1 = { + id: "1", + provider: "snippets", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["badge"], + provider: "badge", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + + Router.handleMessageRequest(trigger); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + + const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args; + assert.propertyVal(options, "shouldCache", false); + }); + it("should filter out messages without a trigger (or different) when a triggerId is defined", async () => { + const trigger = { triggerId: "foo" }; + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + provider: "snippets", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["snippets"], + provider: "snippets", + }; + const message3 = { + id: "3", + campaign: "bazcampaign", + groups: ["snippets"], + provider: "snippets", + }; + await Router.setState({ + messages: [message2, message1, message3], + groups: [{ id: "snippets", enabled: true }], + }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake(args => args.messages); + + const result = Router.handleMessageRequest(trigger); + + assert.lengthOf(result, 1); + assert.deepEqual(result[0], message1); + }); + }); + + describe("#uninit", () => { + it("should unregister the trigger listeners", () => { + for (const listener of ASRouterTriggerListeners.values()) { + sandbox.spy(listener, "uninit"); + } + + Router.uninit(); + + for (const listener of ASRouterTriggerListeners.values()) { + assert.calledOnce(listener.uninit); + } + }); + it("should set .dispatchCFRAction to null", () => { + Router.uninit(); + assert.isNull(Router.dispatchCFRAction); + assert.isNull(Router.clearChildMessages); + assert.isNull(Router.sendTelemetry); + }); + it("should save previousSessionEnd", () => { + Router.uninit(); + + assert.calledOnce(Router._storage.set); + assert.calledWithExactly( + Router._storage.set, + "previousSessionEnd", + sinon.match.number + ); + }); + it("should remove the observer for `intl:app-locales-changed`", () => { + sandbox.spy(global.Services.obs, "removeObserver"); + Router.uninit(); + + assert.calledWithExactly( + global.Services.obs.removeObserver, + Router._onLocaleChanged, + "intl:app-locales-changed" + ); + }); + it("should remove the pref observer for `USE_REMOTE_L10N_PREF`", async () => { + sandbox.spy(global.Services.prefs, "removeObserver"); + Router.uninit(); + + // Grab the last call as #uninit() also involves multiple calls of `Services.prefs.removeObserver`. + const call = global.Services.prefs.removeObserver.lastCall; + assert.calledWithExactly(call, USE_REMOTE_L10N_PREF, Router); + }); + }); + + describe("sendNewTabMessage", () => { + it("should construct an appropriate response message", async () => { + Router.loadMessagesFromAllProviders.resetHistory(); + Router.loadMessagesFromAllProviders.onFirstCall().resolves(); + + let message = { + id: "foo", + provider: "snippets", + groups: ["snippets"], + }; + + await Router.setState({ + messages: [message], + providers: [{ id: "snippets" }], + }); + + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + + let response = await Router.sendNewTabMessage({ + tabId: 0, + browser: {}, + }); + + assert.deepEqual(response.message, message); + }); + it("should send an empty object message if no messages are available", async () => { + await Router.setState({ messages: [] }); + let response = await Router.sendNewTabMessage({ + tabId: 0, + browser: {}, + }); + + assert.deepEqual(response.message, {}); + }); + + describe("#addPreviewEndpoint", () => { + it("should make a request to the provided endpoint", async () => { + const url = "https://snippets-admin.mozilla.org/foo"; + const browser = {}; + browser.sendMessageToActor = sandbox.stub(); + + await Router.sendNewTabMessage({ + endpoint: { url }, + tabId: 0, + browser, + }); + + assert.calledWith(global.fetch, url); + assert.lengthOf( + Router.state.providers.filter(p => p.url === url), + 0 + ); + }); + it("should send EnterSnippetPreviewMode when adding a preview endpoint", async () => { + const url = "https://snippets-admin.mozilla.org/foo"; + const browser = {}; + browser.sendMessageToActor = sandbox.stub(); + + await Router.addPreviewEndpoint(url, browser); + + assert.calledWithExactly( + browser.sendMessageToActor, + "EnterSnippetsPreviewMode", + {}, + "ASRouter" + ); + }); + it("should not add a url that is not from an allowed host", async () => { + const url = "https://mozilla.org"; + const browser = {}; + browser.sendMessageToActor = sandbox.stub(); + + await Router.addPreviewEndpoint(url, browser); + + assert.lengthOf( + Router.state.providers.filter(p => p.url === url), + 0 + ); + }); + it("should reject bad urls", async () => { + const url = "foo"; + const browser = {}; + browser.sendMessageToActor = sandbox.stub(); + + await Router.addPreviewEndpoint(url, browser); + + assert.lengthOf( + Router.state.providers.filter(p => p.url === url), + 0 + ); + }); + }); + + it("should record telemetry for message request duration", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + sandbox.stub(Router, "handleMessageRequest"); + const tabId = 123; + await Router.sendNewTabMessage({ + tabId, + browser: {}, + }); + + // Called once for the messagesLoaded trigger and once for the above call. + assert.calledTwice(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + assert.calledTwice(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + }); + it("should return the preview message if that's available and remove it from Router.state", async () => { + const expectedObj = { + id: "foo", + groups: ["preview"], + provider: "preview", + }; + await Router.setState({ + messages: [expectedObj], + providers: [{ id: "preview" }], + }); + + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => expectedObj + ); + + Router.loadMessagesFromAllProviders.resetHistory(); + Router.loadMessagesFromAllProviders.onFirstCall().resolves(); + + let response = await Router.sendNewTabMessage({ + endpoint: { url: "foo.com" }, + tabId: 0, + browser: {}, + }); + + assert.deepEqual(response.message, expectedObj); + + assert.isUndefined( + Router.state.messages.find(m => m.provider === "preview") + ); + }); + }); + + describe("#setMessageById", async () => { + it("should send an empty message if provided id did not resolve to a message", async () => { + let response = await Router.setMessageById({ id: -1 }, true, {}); + assert.deepEqual(response.message, {}); + }); + }); + + describe("#isUnblockedMessage", () => { + it("should block a message if the group is blocked", async () => { + const msg = { id: "msg1", groups: ["foo"], provider: "unit-test" }; + await Router.setState({ + groups: [{ id: "foo", enabled: false }], + messages: [msg], + providers: [{ id: "unit-test" }], + }); + assert.isFalse(Router.isUnblockedMessage(msg)); + + await Router.setState({ groups: [{ id: "foo", enabled: true }] }); + + assert.isTrue(Router.isUnblockedMessage(msg)); + }); + it("should block a message if at least one group is blocked", async () => { + const msg = { + id: "msg1", + groups: ["foo", "bar"], + provider: "unit-test", + }; + await Router.setState({ + groups: [ + { id: "foo", enabled: false }, + { id: "bar", enabled: false }, + ], + messages: [msg], + providers: [{ id: "unit-test" }], + }); + assert.isFalse(Router.isUnblockedMessage(msg)); + + await Router.setState({ + groups: [ + { id: "foo", enabled: true }, + { id: "bar", enabled: false }, + ], + }); + + assert.isFalse(Router.isUnblockedMessage(msg)); + }); + }); + + describe("#blockMessageById", () => { + it("should add the id to the messageBlockList", async () => { + await Router.blockMessageById("foo"); + assert.isTrue(Router.state.messageBlockList.includes("foo")); + }); + it("should add the campaign to the messageBlockList instead of id if .campaign is specified and not select messages of that campaign again", async () => { + await Router.setState({ + messages: [ + { id: "1", campaign: "foocampaign" }, + { id: "2", campaign: "foocampaign" }, + ], + }); + await Router.blockMessageById("1"); + + assert.isTrue(Router.state.messageBlockList.includes("foocampaign")); + assert.isEmpty(Router.state.messages.filter(Router.isUnblockedMessage)); + }); + it("should be able to add multiple items to the messageBlockList", async () => { + await await Router.blockMessageById(FAKE_BUNDLE.map(b => b.id)); + assert.isTrue(Router.state.messageBlockList.includes(FAKE_BUNDLE[0].id)); + assert.isTrue(Router.state.messageBlockList.includes(FAKE_BUNDLE[1].id)); + }); + it("should save the messageBlockList", async () => { + await await Router.blockMessageById(FAKE_BUNDLE.map(b => b.id)); + assert.calledWithExactly(Router._storage.set, "messageBlockList", [ + FAKE_BUNDLE[0].id, + FAKE_BUNDLE[1].id, + ]); + }); + }); + + describe("#unblockMessageById", () => { + it("should remove the id from the messageBlockList", async () => { + await Router.blockMessageById("foo"); + assert.isTrue(Router.state.messageBlockList.includes("foo")); + await Router.unblockMessageById("foo"); + assert.isFalse(Router.state.messageBlockList.includes("foo")); + }); + it("should remove the campaign from the messageBlockList if it is defined", async () => { + await Router.setState({ messages: [{ id: "1", campaign: "foo" }] }); + await Router.blockMessageById("1"); + assert.isTrue( + Router.state.messageBlockList.includes("foo"), + "blocklist has campaign id" + ); + await Router.unblockMessageById("1"); + assert.isFalse( + Router.state.messageBlockList.includes("foo"), + "campaign id removed from blocklist" + ); + }); + it("should save the messageBlockList", async () => { + await Router.unblockMessageById("foo"); + assert.calledWithExactly(Router._storage.set, "messageBlockList", []); + }); + }); + + describe("#routeCFRMessage", () => { + it("should allow for echoing back message modifications", () => { + const message = { somekey: "some value" }; + const data = { content: message }; + const browser = {}; + let msg = Router.routeCFRMessage(data.content, browser, data, false); + assert.deepEqual(msg.message, message); + }); + it("should call CFRPageActions.forceRecommendation if the template is cfr_action and force is true", async () => { + sandbox.stub(CFRPageActions, "forceRecommendation"); + const testMessage = { id: "foo", template: "cfr_doorhanger" }; + await Router.setState({ messages: [testMessage] }); + Router.routeCFRMessage(testMessage, {}, null, true); + + assert.calledOnce(CFRPageActions.forceRecommendation); + }); + it("should call CFRPageActions.addRecommendation if the template is cfr_action and force is false", async () => { + sandbox.stub(CFRPageActions, "addRecommendation"); + const testMessage = { id: "foo", template: "cfr_doorhanger" }; + await Router.setState({ messages: [testMessage] }); + Router.routeCFRMessage(testMessage, {}, {}, false); + assert.calledOnce(CFRPageActions.addRecommendation); + }); + }); + + describe("#updateTargetingParameters", () => { + it("should return an object containing the whole state", async () => { + sandbox.stub(Router, "getTargetingParameters").resolves({}); + let msg = await Router.updateTargetingParameters(); + let expected = Object.assign({}, Router.state, { + providerPrefs: ASRouterPreferences.providers, + userPrefs: ASRouterPreferences.getAllUserPreferences(), + targetingParameters: {}, + errors: Router.errors, + }); + + assert.deepEqual(msg, expected); + }); + }); + + describe("#reachEvent", () => { + let experimentAPIStub; + let featureIds = ["cfr", "moments-page", "infobar", "spotlight"]; + beforeEach(() => { + let getExperimentMetaDataStub = sandbox.stub(); + let getAllBranchesStub = sandbox.stub(); + featureIds.forEach(feature => { + global.NimbusFeatures[feature].getAllVariables.returns({ + id: `message-${feature}`, + }); + getExperimentMetaDataStub.withArgs({ featureId: feature }).returns({ + slug: `slug-${feature}`, + branch: { + slug: `branch-${feature}`, + }, + }); + getAllBranchesStub.withArgs(`slug-${feature}`).resolves([ + { + slug: `other-branch-${feature}`, + [feature]: { value: { trigger: "unit-test" } }, + }, + ]); + }); + experimentAPIStub = { + getExperimentMetaData: getExperimentMetaDataStub, + getAllBranches: getAllBranchesStub, + }; + globals.set("ExperimentAPI", experimentAPIStub); + }); + afterEach(() => { + sandbox.restore(); + }); + it("should tag `forReachEvent` for all the expected message types", async () => { + // This should match the `providers.messaging-experiments` + let response = await MessageLoaderUtils.loadMessagesForProvider({ + type: "remote-experiments", + featureIds, + }); + + // 1 message for reach 1 for expose + assert.property(response, "messages"); + assert.lengthOf(response.messages, featureIds.length * 2); + assert.lengthOf( + response.messages.filter(m => m.forReachEvent), + featureIds.length + ); + }); + }); + + describe("#sendTriggerMessage", () => { + it("should pass the trigger to ASRouterTargeting when sending trigger message", async () => { + await Router.setState({ + messages: [ + { + id: "foo1", + provider: "onboarding", + template: "onboarding", + trigger: { id: "firstRun" }, + content: { title: "Foo1", body: "Foo123-1" }, + groups: ["onboarding"], + }, + ], + providers: [{ id: "onboarding" }], + }); + + Router.loadMessagesFromAllProviders.resetHistory(); + Router.loadMessagesFromAllProviders.onFirstCall().resolves(); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "firstRun", + }); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + assert.deepEqual( + ASRouterTargeting.findMatchingMessage.firstCall.args[0].trigger, + { + id: "firstRun", + param: undefined, + context: undefined, + } + ); + }); + it("should record telemetry information", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + + const tabId = 123; + + await Router.sendTriggerMessage({ + tabId, + browser: {}, + id: "firstRun", + }); + + assert.calledTwice(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + assert.calledTwice(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + }); + it("should have previousSessionEnd in the message context", () => { + assert.propertyVal( + Router._getMessagesContext(), + "previousSessionEnd", + 100 + ); + }); + it("should record the Reach event if found any", async () => { + let messages = [ + { + id: "foo1", + forReachEvent: { sent: false, group: "cfr" }, + experimentSlug: "exp01", + branchSlug: "branch01", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + { + id: "foo2", + template: "simple_template", + trigger: { id: "bar" }, + content: { title: "Foo2", body: "Foo123-2" }, + provider: "onboarding", + }, + { + id: "foo3", + forReachEvent: { sent: false, group: "cfr" }, + experimentSlug: "exp02", + branchSlug: "branch02", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + sandbox.spy(Services.telemetry, "recordEvent"); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + + assert.calledTwice(Services.telemetry.recordEvent); + }); + it("should not record the Reach event if it's already sent", async () => { + let messages = [ + { + id: "foo1", + forReachEvent: { sent: true, group: "cfr" }, + experimentSlug: "exp01", + branchSlug: "branch01", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + sandbox.spy(Services.telemetry, "recordEvent"); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + assert.notCalled(Services.telemetry.recordEvent); + }); + it("should record the Exposure event for each valid feature", async () => { + ["cfr_doorhanger", "update_action", "infobar", "spotlight"].forEach( + async template => { + let featureMap = { + cfr_doorhanger: "cfr", + spotlight: "spotlight", + infobar: "infobar", + update_action: "moments-page", + }; + assert.notCalled( + global.NimbusFeatures[featureMap[template]].recordExposureEvent + ); + + let messages = [ + { + id: "foo1", + template, + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + + assert.calledOnce( + global.NimbusFeatures[featureMap[template]].recordExposureEvent + ); + } + ); + }); + }); + + describe("forceAttribution", () => { + let setReferrerUrl; + beforeEach(() => { + setReferrerUrl = sinon.spy(); + global.Cc["@mozilla.org/mac-attribution;1"] = { + getService: () => ({ setReferrerUrl }), + }; + + sandbox.stub(global.Services.env, "set"); + }); + it("should double encode on windows", async () => { + sandbox.stub(fakeAttributionCode, "writeAttributionFile"); + + Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" }); + + assert.notCalled(setReferrerUrl); + assert.calledWithMatch( + fakeAttributionCode.writeAttributionFile, + "foo%3DFOO!%26bar%3DBAR%253F" + ); + }); + it("should set referrer on mac", async () => { + sandbox.stub(global.AppConstants, "platform").value("macosx"); + + Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" }); + + assert.calledOnce(setReferrerUrl); + assert.calledWithMatch(setReferrerUrl, "", "?foo=FOO!&bar=BAR%3F"); + }); + }); + + describe("#forceWNPanel", () => { + let browser = { + ownerGlobal: { + document: new Document(), + PanelUI: { + showSubView: sinon.stub(), + panel: { + setAttribute: sinon.stub(), + }, + }, + }, + }; + let fakePanel = { + setAttribute: sinon.stub(), + }; + sinon + .stub(browser.ownerGlobal.document, "getElementById") + .returns(fakePanel); + + it("should call enableToolbarButton", async () => { + await Router.forceWNPanel(browser); + assert.calledOnce(FakeToolbarPanelHub.enableToolbarButton); + assert.calledOnce(browser.ownerGlobal.PanelUI.showSubView); + assert.calledWith(fakePanel.setAttribute, "noautohide", true); + }); + }); + + describe("_triggerHandler", () => { + it("should call #sendTriggerMessage with the correct trigger", () => { + const getter = sandbox.stub(); + getter.returns(false); + sandbox.stub(global.BrowserHandler, "kiosk").get(getter); + sinon.spy(Router, "sendTriggerMessage"); + const browser = {}; + const trigger = { id: "FAKE_TRIGGER", param: "some fake param" }; + Router._triggerHandler(browser, trigger); + assert.calledOnce(Router.sendTriggerMessage); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ + id: "FAKE_TRIGGER", + param: "some fake param", + }) + ); + }); + }); + + describe("_triggerHandler_kiosk", () => { + it("should not call #sendTriggerMessage", () => { + const getter = sandbox.stub(); + getter.returns(true); + sandbox.stub(global.BrowserHandler, "kiosk").get(getter); + sinon.spy(Router, "sendTriggerMessage"); + const browser = {}; + const trigger = { id: "FAKE_TRIGGER", param: "some fake param" }; + Router._triggerHandler(browser, trigger); + assert.notCalled(Router.sendTriggerMessage); + }); + }); + + describe("valid preview endpoint", () => { + it("should report an error if url protocol is not https", () => { + sandbox.stub(console, "error"); + + assert.equal(false, Router._validPreviewEndpoint("http://foo.com")); + assert.calledTwice(console.error); + }); + }); + + describe("impressions", () => { + describe("#addImpression for groups", () => { + it("should save an impression in each group-with-frequency in a message", async () => { + const fooMessageImpressions = [0]; + const aGroupImpressions = [0, 1, 2]; + const bGroupImpressions = [3, 4, 5]; + const cGroupImpressions = [6, 7, 8]; + + const message = { + id: "foo", + provider: "bar", + groups: ["a", "b", "c"], + }; + const groups = [ + { id: "a", frequency: { lifetime: 3 } }, + { id: "b", frequency: { lifetime: 4 } }, + { id: "c", frequency: { lifetime: 5 } }, + ]; + await Router.setState(state => { + // Add provider + const providers = [...state.providers]; + // Add fooMessageImpressions + // eslint-disable-next-line no-shadow + const messageImpressions = Object.assign( + {}, + state.messageImpressions + ); + let gImpressions = {}; + gImpressions.a = aGroupImpressions; + gImpressions.b = bGroupImpressions; + gImpressions.c = cGroupImpressions; + messageImpressions.foo = fooMessageImpressions; + return { + providers, + messageImpressions, + groups, + groupImpressions: gImpressions, + }; + }); + + await Router.addImpression(message); + + assert.deepEqual( + Router.state.groupImpressions.a, + [0, 1, 2, 0], + "a impressions" + ); + assert.deepEqual( + Router.state.groupImpressions.b, + [3, 4, 5, 0], + "b impressions" + ); + assert.deepEqual( + Router.state.groupImpressions.c, + [6, 7, 8, 0], + "c impressions" + ); + }); + }); + + describe("#isBelowFrequencyCaps", () => { + it("should call #_isBelowItemFrequencyCap for the message and for the provider with the correct impressions and arguments", async () => { + sinon.spy(Router, "_isBelowItemFrequencyCap"); + + const MAX_MESSAGE_LIFETIME_CAP = 100; // Defined in ASRouter + const fooMessageImpressions = [0, 1]; + const barGroupImpressions = [0, 1, 2]; + + const message = { + id: "foo", + provider: "bar", + groups: ["bar"], + frequency: { lifetime: 3 }, + }; + const groups = [{ id: "bar", frequency: { lifetime: 5 } }]; + + await Router.setState(state => { + // Add provider + const providers = [...state.providers]; + // Add fooMessageImpressions + // eslint-disable-next-line no-shadow + const messageImpressions = Object.assign( + {}, + state.messageImpressions + ); + let gImpressions = {}; + gImpressions.bar = barGroupImpressions; + messageImpressions.foo = fooMessageImpressions; + return { + providers, + messageImpressions, + groups, + groupImpressions: gImpressions, + }; + }); + + await Router.isBelowFrequencyCaps(message); + + assert.calledTwice(Router._isBelowItemFrequencyCap); + assert.calledWithExactly( + Router._isBelowItemFrequencyCap, + message, + fooMessageImpressions, + MAX_MESSAGE_LIFETIME_CAP + ); + assert.calledWithExactly( + Router._isBelowItemFrequencyCap, + groups[0], + barGroupImpressions + ); + }); + }); + + describe("#_isBelowItemFrequencyCap", () => { + it("should return false if the # of impressions exceeds the maxLifetimeCap", () => { + const item = { id: "foo", frequency: { lifetime: 5 } }; + const impressions = [0, 1]; + const maxLifetimeCap = 1; + const result = Router._isBelowItemFrequencyCap( + item, + impressions, + maxLifetimeCap + ); + assert.isFalse(result); + }); + + describe("lifetime frequency caps", () => { + it("should return true if .frequency is not defined on the item", () => { + const item = { id: "foo" }; + const impressions = [0, 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return true if there are no impressions", () => { + const item = { + id: "foo", + frequency: { + lifetime: 10, + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + }, + }; + const impressions = []; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return true if the # of impressions is less than .frequency.lifetime of the item", () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if the # of impressions is equal to .frequency.lifetime of the item", async () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1, 2]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return false if the # of impressions is greater than .frequency.lifetime of the item", async () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1, 2, 3]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + }); + + describe("custom frequency caps", () => { + it("should return true if impressions in the time period < the cap and total impressions < the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "foo", + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + lifetime: 3, + }, + }; + const impressions = [0, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if impressions in the time period > the cap and total impressions < the lifetime cap", () => { + clock.tick(200); + const item = { + id: "msg1", + frequency: { custom: [{ period: 100, cap: 2 }], lifetime: 3 }, + }; + const impressions = [0, 160, 161]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return false if impressions in one of the time periods > the cap and total impressions < the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 200); + const itemTrue = { + id: "msg2", + frequency: { custom: [{ period: 100, cap: 2 }] }, + }; + const itemFalse = { + id: "msg1", + frequency: { + custom: [ + { period: 100, cap: 2 }, + { period: ONE_DAY_IN_MS, cap: 3 }, + ], + }, + }; + const impressions = [ + 0, + ONE_DAY_IN_MS + 160, + ONE_DAY_IN_MS - 100, + ONE_DAY_IN_MS - 200, + ]; + assert.isTrue(Router._isBelowItemFrequencyCap(itemTrue, impressions)); + assert.isFalse( + Router._isBelowItemFrequencyCap(itemFalse, impressions) + ); + }); + it("should return false if impressions in the time period < the cap and total impressions > the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + lifetime: 3, + }, + }; + const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return true if daily impressions < the daily cap and there is no lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] }, + }; + const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if daily impressions > the daily cap and there is no lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] }, + }; + const impressions = [ + 0, + 1, + 2, + 3, + ONE_DAY_IN_MS + 1, + ONE_DAY_IN_MS + 2, + ONE_DAY_IN_MS + 3, + ]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + }); + }); + + describe("#getLongestPeriod", () => { + it("should return the period if there is only one definition", () => { + const message = { + id: "foo", + frequency: { custom: [{ period: 200, cap: 2 }] }, + }; + assert.equal(Router.getLongestPeriod(message), 200); + }); + it("should return the longest period if there are more than one definitions", () => { + const message = { + id: "foo", + frequency: { + custom: [ + { period: 1000, cap: 3 }, + { period: ONE_DAY_IN_MS, cap: 5 }, + { period: 100, cap: 2 }, + ], + }, + }; + assert.equal(Router.getLongestPeriod(message), ONE_DAY_IN_MS); + }); + it("should return null if there are is no .frequency", () => { + const message = { id: "foo" }; + assert.isNull(Router.getLongestPeriod(message)); + }); + it("should return null if there are is no .frequency.custom", () => { + const message = { id: "foo", frequency: { lifetime: 10 } }; + assert.isNull(Router.getLongestPeriod(message)); + }); + }); + + describe("cleanup on init", () => { + it("should clear messageImpressions for messages which do not exist in state.messages", async () => { + const messages = [{ id: "foo", frequency: { lifetime: 10 } }]; + messageImpressions = { foo: [0], bar: [0, 1] }; + // Impressions for "bar" should be removed since that id does not exist in messages + const result = { foo: [0] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions older than the period if no lifetime impression cap is included", async () => { + const CURRENT_TIME = ONE_DAY_IN_MS * 2; + clock.tick(CURRENT_TIME); + const messages = [ + { + id: "foo", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 5 }] }, + }, + ]; + messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] }; + // Only 0 and 1 are more than 24 hours before CURRENT_TIME + const result = { foo: [CURRENT_TIME - 10] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions older than the longest period if no lifetime impression cap is included", async () => { + const CURRENT_TIME = ONE_DAY_IN_MS * 2; + clock.tick(CURRENT_TIME); + const messages = [ + { + id: "foo", + frequency: { + custom: [ + { period: ONE_DAY_IN_MS, cap: 5 }, + { period: 100, cap: 2 }, + ], + }, + }, + ]; + messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] }; + // Only 0 and 1 are more than 24 hours before CURRENT_TIME + const result = { foo: [CURRENT_TIME - 10] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions if they are not properly formatted", async () => { + const messages = [{ id: "foo", frequency: { lifetime: 10 } }]; + // this is impromperly formatted since messageImpressions are supposed to be an array + messageImpressions = { foo: 0 }; + const result = {}; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should not clear messageImpressions for messages which do exist in state.messages", async () => { + const messages = [ + { id: "foo", frequency: { lifetime: 10 } }, + { id: "bar", frequency: { lifetime: 10 } }, + ]; + messageImpressions = { foo: [0], bar: [] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.notCalled(Router._storage.set); + assert.deepEqual(Router.state.messageImpressions, messageImpressions); + }); + }); + }); + + describe("#_onLocaleChanged", () => { + it("should call _maybeUpdateL10nAttachment in the handler", async () => { + sandbox.spy(Router, "_maybeUpdateL10nAttachment"); + await Router._onLocaleChanged(); + + assert.calledOnce(Router._maybeUpdateL10nAttachment); + }); + }); + + describe("#_maybeUpdateL10nAttachment", () => { + it("should update the l10n attachment if the locale was changed", async () => { + const getter = sandbox.stub(); + getter.onFirstCall().returns("en-US"); + getter.onSecondCall().returns("fr"); + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(getter); + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + sandbox.spy(Router, "setState"); + Router.loadMessagesFromAllProviders.resetHistory(); + + await Router._maybeUpdateL10nAttachment(); + + assert.calledWith(Router.setState, { + localeInUse: "fr", + providers: [ + { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + lastUpdated: undefined, + errors: [], + }, + ], + }); + assert.calledOnce(Router.loadMessagesFromAllProviders); + }); + it("should not update the l10n attachment if the provider doesn't need l10n attachment", async () => { + const getter = sandbox.stub(); + getter.onFirstCall().returns("en-US"); + getter.onSecondCall().returns("fr"); + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(getter); + const provider = { + id: "localProvider", + enabled: true, + type: "local", + }; + await createRouterAndInit([provider]); + Router.loadMessagesFromAllProviders.resetHistory(); + sandbox.spy(Router, "setState"); + + await Router._maybeUpdateL10nAttachment(); + + assert.notCalled(Router.setState); + assert.notCalled(Router.loadMessagesFromAllProviders); + }); + }); + describe("#observe", () => { + it("should reload l10n for CFRPageActions when the `USE_REMOTE_L10N_PREF` pref is changed", () => { + sandbox.spy(CFRPageActions, "reloadL10n"); + + Router.observe("", "", USE_REMOTE_L10N_PREF); + + assert.calledOnce(CFRPageActions.reloadL10n); + }); + it("should not react to other pref changes", () => { + sandbox.spy(CFRPageActions, "reloadL10n"); + + Router.observe("", "", "foo"); + + assert.notCalled(CFRPageActions.reloadL10n); + }); + }); + describe("#loadAllMessageGroups", () => { + it("should disable the group if the pref is false", async () => { + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(false); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: true, + type: "remote", + userPreferences: ["cfrAddons"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", false); + }); + it("should enable the group if at least one pref is true", async () => { + sandbox + .stub(ASRouterPreferences, "getUserPreference") + .withArgs("cfrAddons") + .returns(false) + .withArgs("cfrFeatures") + .returns(true); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: true, + type: "remote", + userPreferences: ["cfrAddons", "cfrFeatures"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", true); + }); + it("should be keep the group disabled if disabled is true", async () => { + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(true); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: false, + type: "remote", + userPreferences: ["cfrAddons"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", false); + }); + it("should keep local groups unchanged if provider doesn't require an update", async () => { + sandbox.stub(MessageLoaderUtils, "shouldProviderUpdate").returns(false); + sandbox.stub(MessageLoaderUtils, "_loadDataForProvider"); + await Router.setState({ + groups: [ + { + id: "cfr", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "cfr"); + + assert.ok(group); + assert.propertyVal(group, "enabled", true); + // Because it should not have updated + assert.notCalled(MessageLoaderUtils._loadDataForProvider); + }); + it("should update local groups on pref change (no RS update)", async () => { + sandbox.stub(MessageLoaderUtils, "shouldProviderUpdate").returns(false); + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(false); + await Router.setState({ + groups: [ + { + id: "cfr", + enabled: true, + collection: "collection", + type: "remote-settings", + userPreferences: ["cfrAddons"], + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "cfr"); + + assert.ok(group); + // Pref changed, updated the group state + assert.propertyVal(group, "enabled", false); + }); + }); + describe("unblockAll", () => { + it("Clears the message block list and returns the state value", async () => { + await Router.setState({ messageBlockList: ["one", "two", "three"] }); + assert.equal(Router.state.messageBlockList.length, 3); + const state = await Router.unblockAll(); + assert.equal(Router.state.messageBlockList.length, 0); + assert.equal(state.messageBlockList.length, 0); + }); + }); + describe("#loadMessagesForProvider", () => { + it("should fetch messages from the ExperimentAPI", async () => { + const args = { + type: "remote-experiments", + featureIds: ["spotlight"], + }; + + await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.calledOnce(global.NimbusFeatures.spotlight.getAllVariables); + assert.calledOnce(global.ExperimentAPI.getExperimentMetaData); + assert.calledWithExactly(global.ExperimentAPI.getExperimentMetaData, { + featureId: "spotlight", + }); + }); + it("should handle the case of no experiments in the ExperimentAPI", async () => { + const args = { + type: "remote-experiments", + featureIds: ["infobar"], + }; + + global.ExperimentAPI.getExperiment.returns(null); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 0); + }); + it("should normally load ExperimentAPI messages", async () => { + const args = { + type: "remote-experiments", + featureIds: ["infobar"], + }; + const enrollment = { + branch: { + slug: "branch01", + infobar: { + featureId: "infobar", + value: { id: "id01", trigger: { id: "openURL" } }, + }, + }, + }; + + global.NimbusFeatures.infobar.getAllVariables.returns( + enrollment.branch.infobar.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.returns([ + enrollment.branch, + { + slug: "control", + infobar: { + featureId: "infobar", + value: null, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 1); + }); + it("should skip disabled features and not load the messages", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + + global.NimbusFeatures.cfr.getAllVariables.returns(null); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 0); + }); + it("should fetch branches with trigger", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + const enrollment = { + slug: "exp01", + branch: { + slug: "branch01", + cfr: { + featureId: "cfr", + value: { id: "id01", trigger: { id: "openURL" } }, + }, + }, + }; + + global.NimbusFeatures.cfr.getAllVariables.returns( + enrollment.branch.cfr.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + slug: enrollment.slug, + active: true, + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.resolves([ + enrollment.branch, + { + slug: "branch02", + cfr: { + featureId: "cfr", + value: { id: "id02", trigger: { id: "openURL" } }, + }, + }, + { + // This branch should not be loaded as it doesn't have the trigger + slug: "branch03", + cfr: { + featureId: "cfr", + value: { id: "id03" }, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.equal(result.messages.length, 2); + assert.equal(result.messages[0].id, "id01"); + assert.equal(result.messages[1].id, "id02"); + assert.equal(result.messages[1].experimentSlug, "exp01"); + assert.equal(result.messages[1].branchSlug, "branch02"); + assert.deepEqual(result.messages[1].forReachEvent, { + sent: false, + group: "cfr", + }); + }); + it("should fetch branches with trigger even if enrolled branch is disabled", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + const enrollment = { + slug: "exp01", + branch: { + slug: "branch01", + cfr: { + featureId: "cfr", + value: {}, + }, + }, + }; + + // Nedds to match the `featureIds` value to return an enrollment + // for that feature + global.NimbusFeatures.cfr.getAllVariables.returns( + enrollment.branch.cfr.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + slug: enrollment.slug, + active: true, + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.resolves([ + enrollment.branch, + { + slug: "branch02", + cfr: { + featureId: "cfr", + value: { id: "id02", trigger: { id: "openURL" } }, + }, + }, + { + // This branch should not be loaded as it doesn't have the trigger + slug: "branch03", + cfr: { + featureId: "cfr", + value: { id: "id03" }, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.equal(result.messages.length, 1); + assert.equal(result.messages[0].id, "id02"); + assert.equal(result.messages[0].experimentSlug, "exp01"); + assert.equal(result.messages[0].branchSlug, "branch02"); + assert.deepEqual(result.messages[0].forReachEvent, { + sent: false, + group: "cfr", + }); + }); + }); + describe("#_remoteSettingsLoader", () => { + let provider; + let spy; + beforeEach(() => { + provider = { + id: "cfr", + collection: "cfr", + }; + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + spy = sandbox.spy(); + global.Downloader.prototype.downloadToDisk = spy; + }); + it("should be called with the expected dir path", async () => { + const dlSpy = sandbox.spy(global, "Downloader"); + + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.calledWith( + dlSpy, + "main", + "ms-language-packs", + "browser", + "newtab" + ); + }); + it("should allow fetch for known locales", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.calledOnce(spy); + }); + it("should fallback to 'en-US' for locale 'und' ", async () => { + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(() => "und"); + const getRecordSpy = sandbox.spy( + global.KintoHttpClient.prototype, + "getRecord" + ); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.ok(getRecordSpy.args[0][0].includes("en-US")); + assert.calledOnce(spy); + }); + it("should fallback to 'ja-JP-mac' for locale 'ja-JP-macos'", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "ja-JP-macos"); + const getRecordSpy = sandbox.spy( + global.KintoHttpClient.prototype, + "getRecord" + ); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.ok(getRecordSpy.args[0][0].includes("ja-JP-mac")); + assert.calledOnce(spy); + }); + it("should not allow fetch for unsupported locales", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "unkown"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.notCalled(spy); + }); + }); + describe("#resetMessageState", () => { + it("should reset all message impressions", async () => { + await Router.setState({ + messages: [{ id: "1" }, { id: "2" }], + }); + await Router.setState({ + messageImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] }, + }); // Add impressions for test messages + let impressions = Object.values(Router.state.messageImpressions); + assert.equal(impressions.filter(i => i.length).length, 2); // Both messages have impressions + + Router.resetMessageState(); + impressions = Object.values(Router.state.messageImpressions); + + assert.isEmpty(impressions.filter(i => i.length)); // Both messages now have zero impressions + assert.calledWithExactly(Router._storage.set, "messageImpressions", { + 1: [], + 2: [], + }); + }); + }); + describe("#resetGroupsState", () => { + it("should reset all group impressions", async () => { + await Router.setState({ + groups: [{ id: "1" }, { id: "2" }], + }); + await Router.setState({ + groupImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] }, + }); // Add impressions for test groups + let impressions = Object.values(Router.state.groupImpressions); + assert.equal(impressions.filter(i => i.length).length, 2); // Both groups have impressions + + Router.resetGroupsState(); + impressions = Object.values(Router.state.groupImpressions); + + assert.isEmpty(impressions.filter(i => i.length)); // Both groups now have zero impressions + assert.calledWithExactly(Router._storage.set, "groupImpressions", { + 1: [], + 2: [], + }); + }); + }); +}); -- cgit v1.2.3