diff options
Diffstat (limited to 'browser/components/asrouter/tests/unit')
22 files changed, 10871 insertions, 0 deletions
diff --git a/browser/components/asrouter/tests/unit/ASRouter.test.js b/browser/components/asrouter/tests/unit/ASRouter.test.js new file mode 100644 index 0000000000..7df1449a14 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouter.test.js @@ -0,0 +1,2870 @@ +import { _ASRouter, MessageLoaderUtils } from "modules/ASRouter.sys.mjs"; +import { QueryCache } from "modules/ASRouterTargeting.sys.mjs"; +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 "modules/ASRouterPreferences.sys.mjs"; +import { ASRouterTriggerListeners } from "modules/ASRouterTriggerListeners.sys.mjs"; +import { CFRPageActions } from "modules/CFRPageActions.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; +import { PanelTestProvider } from "modules/PanelTestProvider.sys.mjs"; +import ProviderResponseSchema from "content-src/schemas/provider-response.schema.json"; + +const MESSAGE_PROVIDER_PREF_NAME = + "browser.newtabpage.activity-stream.asrouter.providers.cfr"; +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 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": "", + "whats-new-panel": "", + }, + totalBookmarksCount: {}, + firefoxVersion: 80, + region: "US", + needsUpdate: {}, + hasPinnedTabs: false, + hasAccessedFxAPanel: false, + isWhatsNewPanelEnabled: true, + userPrefs: { + cfrFeatures: true, + cfrAddons: 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, + }); + 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 `defineLazyModuleGetters` 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, + 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 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, "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, "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: ["cfr"], + provider: "cfr", + }; + const messageNotTargeted = { + id: "2", + campaign: "foocampaign", + groups: ["cfr"], + provider: "cfr", + }; + await Router.setState({ + messages: [messageTargeted, messageNotTargeted], + providers: [{ id: "cfr" }], + }); + 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: "some_other_template" }, + 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.sys.mjs 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: "cfr" }, { id: "badge" }], + })); + }); + it("should not return a blocked message", async () => { + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { id: "foo", provider: "cfr", groups: ["cfr"] }, + { id: "bar", provider: "cfr", groups: ["cfr"] }, + ], + messageBlockList: ["foo"], + })); + await Router.handleMessageRequest({ + provider: "cfr", + }); + assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, { + messages: [{ id: "bar", provider: "cfr", groups: ["cfr"] }], + }); + }); + 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: "cfr", groups: ["cfr"] }, + { id: "bar", provider: "cfr", groups: ["cfr"] }, + ], + groups: [{ id: "cfr", enabled: false }], + })); + const result = await Router.handleMessageRequest({ + provider: "cfr", + }); + 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: "cfr", + campaign: "foocampaign", + groups: ["cfr"], + }, + { id: "bar", provider: "cfr", groups: ["cfr"] }, + ], + messageBlockList: ["foocampaign"], + })); + + await Router.handleMessageRequest({ + provider: "cfr", + }); + assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, { + messages: [{ id: "bar", provider: "cfr", groups: ["cfr"] }], + }); + }); + 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: "cfr", exclude: ["foo"] }], + })); + + await Router.setState(() => ({ + messages: [{ id: "foo", provider: "cfr" }], + messageBlockList: ["foocampaign"], + })); + + const result = await Router.handleMessageRequest({ + provider: "cfr", + }); + 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: "cfr" }], + })); + const result = await Router.handleMessageRequest({ + provider: "cfr", + }); + assert.isNull(result); + }); + it("should get unblocked messages that match the trigger", async () => { + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["cfr"], + provider: "cfr", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["cfr"], + provider: "cfr", + }; + 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: "test_template", + trigger: { id: "foo" }, + groups: ["cfr"], + provider: "cfr", + }; + 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: ["cfr"], + provider: "cfr", + }; + 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 not cache badge messages", async () => { + const trigger = { + triggerId: "bar", + triggerParam: "bar", + triggerContext: "context", + }; + const message1 = { + id: "1", + provider: "cfr", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["cfr"], + }; + 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: ["cfr"], + provider: "cfr", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["cfr"], + provider: "cfr", + }; + const message3 = { + id: "3", + campaign: "bazcampaign", + groups: ["cfr"], + provider: "cfr", + }; + await Router.setState({ + messages: [message2, message1, message3], + groups: [{ id: "cfr", 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("#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, + devtoolsEnabled: ASRouterPreferences.devtoolsEnabled, + }); + + 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 setAttributionString; + beforeEach(() => { + setAttributionString = sandbox.spy(Router, "setAttributionString"); + sandbox.stub(global.Services.env, "set"); + }); + afterEach(() => { + sandbox.reset(); + }); + it("should double encode on windows", async () => { + sandbox.stub(fakeAttributionCode, "writeAttributionFile"); + + Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" }); + + assert.notCalled(setAttributionString); + assert.calledWithMatch( + fakeAttributionCode.writeAttributionFile, + "foo%3DFOO!%26bar%3DBAR%253F" + ); + }); + it("should set attribution string on mac", async () => { + sandbox.stub(global.AppConstants, "platform").value("macosx"); + + Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" }); + + assert.calledOnce(setAttributionString); + assert.calledWithMatch( + setAttributionString, + "foo%3DFOO!%26bar%3DBAR%253F" + ); + }); + }); + + 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: [], + }); + }); + }); + describe("#resetScreenImpressions", () => { + it("should reset all screen impressions", async () => { + await Router.setState({ screenImpressions: { 1: 1, 2: 2 } }); + let impressions = Object.values(Router.state.screenImpressions); + assert.equal(impressions.filter(i => i).length, 2); // Both screens have impressions + + Router.resetScreenImpressions(); + impressions = Object.values(Router.state.screenImpressions); + + assert.isEmpty(impressions.filter(i => i)); // Both screens now have zero impressions + assert.calledWithExactly(Router._storage.set, "screenImpressions", {}); + }); + }); + describe("#editState", () => { + it("should update message impressions", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true); + await Router.setState({ messages: [{ id: "1" }, { id: "2" }] }); + await Router.setState({ + messageImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] }, + }); + let impressions = Object.values(Router.state.messageImpressions); + assert.equal(impressions.filter(i => i.length).length, 2); // Both messages have impressions + + Router.editState("messageImpressions", { + 1: [], + 2: [], + 3: [0, 1, 2], + }); + + // The original messages now have zero impressions + assert.isEmpty(Router.state.messageImpressions["1"]); + assert.isEmpty(Router.state.messageImpressions["2"]); + // A new impression array was added for the new message + assert.equal(Router.state.messageImpressions["3"].length, 3); + assert.calledWithExactly(Router._storage.set, "messageImpressions", { + 1: [], + 2: [], + 3: [0, 1, 2], + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterChild.test.js b/browser/components/asrouter/tests/unit/ASRouterChild.test.js new file mode 100644 index 0000000000..41fdd79ea2 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterChild.test.js @@ -0,0 +1,71 @@ +/*eslint max-nested-callbacks: ["error", 10]*/ +import { ASRouterChild } from "actors/ASRouterChild.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("ASRouterChild", () => { + let asRouterChild = null; + let globals = null; + let overrider = null; + let sandbox = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + globals = { + Cu: { + cloneInto: sandbox.stub().returns(Promise.resolve()), + }, + }; + overrider = new GlobalOverrider(); + overrider.set(globals); + asRouterChild = new ASRouterChild(); + asRouterChild.telemetry = { + sendTelemetry: sandbox.stub(), + }; + sandbox.stub(asRouterChild, "sendAsyncMessage"); + sandbox.stub(asRouterChild, "sendQuery").returns(Promise.resolve()); + }); + afterEach(() => { + sandbox.restore(); + overrider.restore(); + asRouterChild = null; + }); + describe("asRouterMessage", () => { + describe("uses sendAsyncMessage for types that don't need an async response", () => { + [ + msg.DISABLE_PROVIDER, + msg.ENABLE_PROVIDER, + msg.EXPIRE_QUERY_CACHE, + msg.FORCE_WHATSNEW_PANEL, + msg.IMPRESSION, + msg.RESET_PROVIDER_PREF, + msg.SET_PROVIDER_USER_PREF, + msg.USER_ACTION, + ].forEach(type => { + it(`type ${type}`, () => { + asRouterChild.asRouterMessage({ + type, + data: { + something: 1, + }, + }); + sandbox.assert.calledOnce(asRouterChild.sendAsyncMessage); + sandbox.assert.calledWith(asRouterChild.sendAsyncMessage, type, { + something: 1, + }); + }); + }); + }); + // Some legacy privileged extensions still send this legacy NEWTAB_MESSAGE_REQUEST + // action type. We simply + it("can accept the legacy NEWTAB_MESSAGE_REQUEST message without throwing", async () => { + assert.doesNotThrow(async () => { + let result = await asRouterChild.asRouterMessage({ + type: "NEWTAB_MESSAGE_REQUEST", + data: {}, + }); + sandbox.assert.deepEqual(result, {}); + sandbox.assert.notCalled(asRouterChild.sendAsyncMessage); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterNewTabHook.test.js b/browser/components/asrouter/tests/unit/ASRouterNewTabHook.test.js new file mode 100644 index 0000000000..664b685881 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterNewTabHook.test.js @@ -0,0 +1,153 @@ +/*eslint max-nested-callbacks: ["error", 10]*/ +import { ASRouterNewTabHook } from "modules/ASRouterNewTabHook.sys.mjs"; + +describe("ASRouterNewTabHook", () => { + let sandbox = null; + let initParams = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + initParams = { + router: { + init: sandbox.stub().callsFake(() => { + // Fake the initialization + initParams.router.initialized = true; + }), + uninit: sandbox.stub(), + }, + messageHandler: { + handleCFRAction: {}, + handleTelemetry: {}, + }, + createStorage: () => Promise.resolve({}), + }; + }); + afterEach(() => { + sandbox.restore(); + }); + describe("ASRouterNewTabHook", () => { + describe("getInstance", () => { + it("awaits createInstance and router init before returning instance", async () => { + const getInstanceCall = sandbox.spy(); + const waitForInstance = + ASRouterNewTabHook.getInstance().then(getInstanceCall); + await ASRouterNewTabHook.createInstance(initParams); + await waitForInstance; + assert.callOrder(initParams.router.init, getInstanceCall); + }); + }); + describe("createInstance", () => { + it("calls router init", async () => { + await ASRouterNewTabHook.createInstance(initParams); + assert.calledOnce(initParams.router.init); + }); + it("only calls router init once", async () => { + initParams.router.init.callsFake(() => { + initParams.router.initialized = true; + }); + await ASRouterNewTabHook.createInstance(initParams); + await ASRouterNewTabHook.createInstance(initParams); + assert.calledOnce(initParams.router.init); + }); + }); + describe("destroy", () => { + it("disconnects new tab, uninits ASRouter, and destroys instance", async () => { + await ASRouterNewTabHook.createInstance(initParams); + const instance = await ASRouterNewTabHook.getInstance(); + const destroy = instance.destroy.bind(instance); + sandbox.stub(instance, "destroy").callsFake(destroy); + ASRouterNewTabHook.destroy(); + assert.calledOnce(initParams.router.uninit); + assert.calledOnce(instance.destroy); + assert.isNotNull(instance); + assert.isNull(instance._newTabMessageHandler); + }); + }); + describe("instance", () => { + let routerParams = null; + let messageHandler = null; + let instance = null; + beforeEach(async () => { + messageHandler = { + clearChildMessages: sandbox.stub().resolves(), + clearChildProviders: sandbox.stub().resolves(), + updateAdminState: sandbox.stub().resolves(), + }; + initParams.router.init.callsFake(params => { + routerParams = params; + }); + await ASRouterNewTabHook.createInstance(initParams); + instance = await ASRouterNewTabHook.getInstance(); + }); + describe("connect", () => { + it("before connection messageHandler methods are not called", async () => { + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + assert.notCalled(messageHandler.clearChildMessages); + assert.notCalled(messageHandler.clearChildProviders); + assert.notCalled(messageHandler.updateAdminState); + }); + it("after connect updateAdminState and clearChildMessages calls are forwarded to handler", async () => { + instance.connect(messageHandler); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + assert.called(messageHandler.clearChildMessages); + assert.called(messageHandler.clearChildProviders); + assert.called(messageHandler.updateAdminState); + }); + it("calls from before connection are dropped", async () => { + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + instance.connect(messageHandler); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + assert.calledOnce(messageHandler.clearChildMessages); + assert.calledOnce(messageHandler.clearChildProviders); + assert.calledOnce(messageHandler.updateAdminState); + }); + }); + describe("disconnect", () => { + it("calls after disconnect are dropped", async () => { + instance.connect(messageHandler); + instance.disconnect(); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + assert.notCalled(messageHandler.clearChildMessages); + assert.notCalled(messageHandler.clearChildProviders); + assert.notCalled(messageHandler.updateAdminState); + }); + it("only calls from when there is a connection are forwarded", async () => { + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["foo"]); + routerParams.updateAdminState({ messages: {} }); + instance.connect(messageHandler); + routerParams.clearChildMessages([200]); + routerParams.clearChildProviders(["bar"]); + routerParams.updateAdminState({ + messages: { + data: "accept", + }, + }); + instance.disconnect(); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["foo"]); + routerParams.updateAdminState({ messages: {} }); + assert.calledOnce(messageHandler.clearChildMessages); + assert.calledOnce(messageHandler.clearChildProviders); + assert.calledOnce(messageHandler.updateAdminState); + assert.calledWith(messageHandler.clearChildMessages, [200]); + assert.calledWith(messageHandler.clearChildProviders, ["bar"]); + assert.calledWith(messageHandler.updateAdminState, { + messages: { + data: "accept", + }, + }); + }); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterParent.test.js b/browser/components/asrouter/tests/unit/ASRouterParent.test.js new file mode 100644 index 0000000000..0358b1261c --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterParent.test.js @@ -0,0 +1,83 @@ +import { ASRouterParent } from "actors/ASRouterParent.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs"; + +describe("ASRouterParent", () => { + let asRouterParent = null; + let sandbox = null; + let handleMessage = null; + let tabs = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + handleMessage = sandbox.stub().resolves("handle-message-result"); + ASRouterParent.nextTabId = 1; + const methods = { + destroy: sandbox.stub(), + size: 1, + messageAll: sandbox.stub().resolves(), + registerActor: sandbox.stub(), + unregisterActor: sandbox.stub(), + loadingMessageHandler: Promise.resolve({ + handleMessage, + }), + }; + tabs = { + methods, + factory: sandbox.stub().returns(methods), + }; + asRouterParent = new ASRouterParent({ tabsFactory: tabs.factory }); + ASRouterParent.tabs = tabs.methods; + asRouterParent.browsingContext = { + embedderElement: { + getAttribute: () => true, + }, + }; + asRouterParent.tabId = ASRouterParent.nextTabId; + }); + afterEach(() => { + sandbox.restore(); + asRouterParent = null; + }); + describe("actorCreated", () => { + it("after ASRouterTabs is instanced", () => { + asRouterParent.actorCreated(); + assert.equal(asRouterParent.tabId, 2); + assert.notCalled(tabs.factory); + assert.calledOnce(tabs.methods.registerActor); + }); + it("before ASRouterTabs is instanced", () => { + ASRouterParent.tabs = null; + ASRouterParent.nextTabId = 0; + asRouterParent.actorCreated(); + assert.calledOnce(tabs.factory); + assert.isNotNull(ASRouterParent.tabs); + assert.equal(asRouterParent.tabId, 1); + }); + }); + describe("didDestroy", () => { + it("one still remains", () => { + ASRouterParent.tabs.size = 1; + asRouterParent.didDestroy(); + assert.isNotNull(ASRouterParent.tabs); + assert.calledOnce(ASRouterParent.tabs.unregisterActor); + assert.notCalled(ASRouterParent.tabs.destroy); + }); + it("none remain", () => { + ASRouterParent.tabs.size = 0; + const tabsCopy = ASRouterParent.tabs; + asRouterParent.didDestroy(); + assert.isNull(ASRouterParent.tabs); + assert.calledOnce(tabsCopy.unregisterActor); + assert.calledOnce(tabsCopy.destroy); + }); + }); + describe("receiveMessage", async () => { + it("passes call to parentProcessMessageHandler and returns the result from handler", async () => { + const result = await asRouterParent.receiveMessage({ + name: msg.BLOCK_MESSAGE_BY_ID, + data: { id: 1 }, + }); + assert.calledOnce(handleMessage); + assert.equal(result, "handle-message-result"); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js b/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js new file mode 100644 index 0000000000..7bfec3e099 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js @@ -0,0 +1,428 @@ +import { ASRouterParentProcessMessageHandler } from "modules/ASRouterParentProcessMessageHandler.sys.mjs"; +import { _ASRouter } from "modules/ASRouter.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs"; + +describe("ASRouterParentProcessMessageHandler", () => { + let handler = null; + let sandbox = null; + let config = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + const returnValue = { value: 1 }; + const router = new _ASRouter(); + [ + "addImpression", + "evaluateExpression", + "forceAttribution", + "forceWNPanel", + "closeWNPanel", + "forcePBWindow", + "resetGroupsState", + "resetMessageState", + "resetScreenImpressions", + "editState", + ].forEach(method => sandbox.stub(router, `${method}`).resolves()); + [ + "blockMessageById", + "loadMessagesFromAllProviders", + "sendTriggerMessage", + "routeCFRMessage", + "setMessageById", + "updateTargetingParameters", + "unblockMessageById", + "unblockAll", + ].forEach(method => + sandbox.stub(router, `${method}`).resolves(returnValue) + ); + router._storage = { + set: sandbox.stub().resolves(), + get: sandbox.stub().resolves(), + }; + sandbox.stub(router, "setState").callsFake(callback => { + if (typeof callback === "function") { + callback({ + messageBlockList: [ + { + id: 0, + }, + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + }, + { + id: 4, + }, + ], + }); + } + return Promise.resolve(returnValue); + }); + const preferences = { + enableOrDisableProvider: sandbox.stub(), + resetProviderPref: sandbox.stub(), + setUserPreference: sandbox.stub(), + }; + const specialMessageActions = { + handleAction: sandbox.stub(), + }; + const queryCache = { + expireAll: sandbox.stub(), + }; + const sendTelemetry = sandbox.stub(); + config = { + router, + preferences, + specialMessageActions, + queryCache, + sendTelemetry, + }; + handler = new ASRouterParentProcessMessageHandler(config); + }); + afterEach(() => { + sandbox.restore(); + handler = null; + config = null; + }); + describe("constructor", () => { + it("does not throw", () => { + assert.isNotNull(handler); + assert.isNotNull(config); + }); + }); + describe("handleCFRAction", () => { + it("non-telemetry type isn't sent to telemetry", () => { + handler.handleCFRAction({ + type: msg.BLOCK_MESSAGE_BY_ID, + data: { id: 1 }, + }); + assert.notCalled(config.sendTelemetry); + assert.calledOnce(config.router.blockMessageById); + }); + it("passes browser to handleMessage", async () => { + await handler.handleCFRAction( + { + type: msg.USER_ACTION, + data: { id: 1 }, + }, + { ownerGlobal: {} } + ); + assert.notCalled(config.sendTelemetry); + assert.calledOnce(config.specialMessageActions.handleAction); + assert.calledWith( + config.specialMessageActions.handleAction, + { id: 1 }, + { ownerGlobal: {} } + ); + }); + [ + msg.AS_ROUTER_TELEMETRY_USER_EVENT, + msg.TOOLBAR_BADGE_TELEMETRY, + msg.TOOLBAR_PANEL_TELEMETRY, + msg.MOMENTS_PAGE_TELEMETRY, + msg.DOORHANGER_TELEMETRY, + ].forEach(type => { + it(`telemetry type "${type}" is sent to telemetry`, () => { + handler.handleCFRAction({ + type, + data: { id: 1 }, + }); + assert.calledOnce(config.sendTelemetry); + assert.notCalled(config.router.blockMessageById); + }); + }); + }); + describe("#handleMessage", () => { + it("#default: should throw for unknown msg types", () => { + handler.handleMessage("err").then( + () => assert.fail("It should not succeed"), + () => assert.ok(true) + ); + }); + describe("#AS_ROUTER_TELEMETRY_USER_EVENT", () => { + it("should route AS_ROUTER_TELEMETRY_USER_EVENT to handleTelemetry", async () => { + const data = { data: "foo" }; + await handler.handleMessage(msg.AS_ROUTER_TELEMETRY_USER_EVENT, data); + + assert.calledOnce(handler.handleTelemetry); + assert.calledWithExactly(handler.handleTelemetry, { + type: msg.AS_ROUTER_TELEMETRY_USER_EVENT, + data, + }); + }); + }); + describe("BLOCK_MESSAGE_BY_ID action", () => { + it("with preventDismiss returns false", async () => { + const result = await handler.handleMessage(msg.BLOCK_MESSAGE_BY_ID, { + id: 1, + preventDismiss: true, + }); + assert.calledOnce(config.router.blockMessageById); + assert.isFalse(result); + }); + it("by default returns true", async () => { + const result = await handler.handleMessage(msg.BLOCK_MESSAGE_BY_ID, { + id: 1, + }); + assert.calledOnce(config.router.blockMessageById); + assert.isTrue(result); + }); + }); + describe("USER_ACTION action", () => { + it("default calls SpecialMessageActions.handleAction", async () => { + await handler.handleMessage( + msg.USER_ACTION, + { + type: "SOMETHING", + }, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.specialMessageActions.handleAction); + assert.calledWith( + config.specialMessageActions.handleAction, + { type: "SOMETHING" }, + { ownerGlobal: {} } + ); + }); + }); + describe("IMPRESSION action", () => { + it("default calls addImpression", () => { + handler.handleMessage(msg.IMPRESSION, { + id: 1, + }); + assert.calledOnce(config.router.addImpression); + }); + }); + describe("TRIGGER action", () => { + it("default calls sendTriggerMessage and returns state", async () => { + const result = await handler.handleMessage( + msg.TRIGGER, + { + trigger: { stuff: {} }, + }, + { id: 100, browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.sendTriggerMessage); + assert.calledWith(config.router.sendTriggerMessage, { + stuff: {}, + tabId: 100, + browser: { ownerGlobal: {} }, + }); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("ADMIN_CONNECT_STATE action", () => { + it("with endpoint url calls loadMessagesFromAllProviders, and returns state", async () => { + const result = await handler.handleMessage(msg.ADMIN_CONNECT_STATE, { + endpoint: { + url: "test", + }, + }); + assert.calledOnce(config.router.loadMessagesFromAllProviders); + assert.deepEqual(result, { value: 1 }); + }); + it("default returns state", async () => { + const result = await handler.handleMessage(msg.ADMIN_CONNECT_STATE); + assert.calledOnce(config.router.updateTargetingParameters); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("UNBLOCK_MESSAGE_BY_ID action", () => { + it("default calls unblockMessageById", async () => { + const result = await handler.handleMessage(msg.UNBLOCK_MESSAGE_BY_ID, { + id: 1, + }); + assert.calledOnce(config.router.unblockMessageById); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("UNBLOCK_ALL action", () => { + it("default calls unblockAll", async () => { + const result = await handler.handleMessage(msg.UNBLOCK_ALL); + assert.calledOnce(config.router.unblockAll); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("BLOCK_BUNDLE action", () => { + it("default calls unblockMessageById", async () => { + const result = await handler.handleMessage(msg.BLOCK_BUNDLE, { + bundle: [ + { + id: 8, + }, + { + id: 13, + }, + ], + }); + assert.calledOnce(config.router.blockMessageById); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("UNBLOCK_BUNDLE action", () => { + it("default calls setState", async () => { + const result = await handler.handleMessage(msg.UNBLOCK_BUNDLE, { + bundle: [ + { + id: 1, + }, + { + id: 3, + }, + ], + }); + assert.calledOnce(config.router.setState); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("DISABLE_PROVIDER action", () => { + it("default calls ASRouterPreferences.enableOrDisableProvider", () => { + handler.handleMessage(msg.DISABLE_PROVIDER, {}); + assert.calledOnce(config.preferences.enableOrDisableProvider); + }); + }); + describe("ENABLE_PROVIDER action", () => { + it("default calls ASRouterPreferences.enableOrDisableProvider", () => { + handler.handleMessage(msg.ENABLE_PROVIDER, {}); + assert.calledOnce(config.preferences.enableOrDisableProvider); + }); + }); + describe("EVALUATE_JEXL_EXPRESSION action", () => { + it("default calls evaluateExpression", () => { + handler.handleMessage(msg.EVALUATE_JEXL_EXPRESSION, {}); + assert.calledOnce(config.router.evaluateExpression); + }); + }); + describe("EXPIRE_QUERY_CACHE action", () => { + it("default calls QueryCache.expireAll", () => { + handler.handleMessage(msg.EXPIRE_QUERY_CACHE); + assert.calledOnce(config.queryCache.expireAll); + }); + }); + describe("FORCE_ATTRIBUTION action", () => { + it("default calls forceAttribution", () => { + handler.handleMessage(msg.FORCE_ATTRIBUTION, {}); + assert.calledOnce(config.router.forceAttribution); + }); + }); + describe("FORCE_WHATSNEW_PANEL action", () => { + it("default calls forceWNPanel", () => { + handler.handleMessage( + msg.FORCE_WHATSNEW_PANEL, + {}, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.forceWNPanel); + assert.calledWith(config.router.forceWNPanel, { ownerGlobal: {} }); + }); + }); + describe("CLOSE_WHATSNEW_PANEL action", () => { + it("default calls closeWNPanel", () => { + handler.handleMessage( + msg.CLOSE_WHATSNEW_PANEL, + {}, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.closeWNPanel); + assert.calledWith(config.router.closeWNPanel, { ownerGlobal: {} }); + }); + }); + describe("FORCE_PRIVATE_BROWSING_WINDOW action", () => { + it("default calls forcePBWindow", () => { + handler.handleMessage( + msg.FORCE_PRIVATE_BROWSING_WINDOW, + {}, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.forcePBWindow); + assert.calledWith(config.router.forcePBWindow, { ownerGlobal: {} }); + }); + }); + describe("MODIFY_MESSAGE_JSON action", () => { + it("default calls routeCFRMessage", async () => { + const result = await handler.handleMessage( + msg.MODIFY_MESSAGE_JSON, + { + content: { + text: "something", + }, + }, + { browser: { ownerGlobal: {} }, id: 100 } + ); + assert.calledOnce(config.router.routeCFRMessage); + assert.calledWith( + config.router.routeCFRMessage, + { text: "something" }, + { ownerGlobal: {} }, + { content: { text: "something" } }, + true + ); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("OVERRIDE_MESSAGE action", () => { + it("default calls setMessageById", async () => { + const result = await handler.handleMessage( + msg.OVERRIDE_MESSAGE, + { + id: 1, + }, + { id: 100, browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.setMessageById); + assert.calledWith(config.router.setMessageById, { id: 1 }, true, { + ownerGlobal: {}, + }); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("RESET_PROVIDER_PREF action", () => { + it("default calls ASRouterPreferences.resetProviderPref", () => { + handler.handleMessage(msg.RESET_PROVIDER_PREF); + assert.calledOnce(config.preferences.resetProviderPref); + }); + }); + describe("SET_PROVIDER_USER_PREF action", () => { + it("default calls ASRouterPreferences.setUserPreference", () => { + handler.handleMessage(msg.SET_PROVIDER_USER_PREF, { + id: 1, + value: true, + }); + assert.calledOnce(config.preferences.setUserPreference); + assert.calledWith(config.preferences.setUserPreference, 1, true); + }); + }); + describe("RESET_GROUPS_STATE action", () => { + it("default calls resetGroupsState, loadMessagesFromAllProviders, and returns state", async () => { + const result = await handler.handleMessage(msg.RESET_GROUPS_STATE, { + property: "value", + }); + assert.calledOnce(config.router.resetGroupsState); + assert.calledOnce(config.router.loadMessagesFromAllProviders); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("RESET_MESSAGE_STATE action", () => { + it("default calls resetMessageState", () => { + handler.handleMessage(msg.RESET_MESSAGE_STATE); + assert.calledOnce(config.router.resetMessageState); + }); + }); + describe("RESET_SCREEN_IMPRESSIONS action", () => { + it("default calls resetScreenImpressions", () => { + handler.handleMessage(msg.RESET_SCREEN_IMPRESSIONS); + assert.calledOnce(config.router.resetScreenImpressions); + }); + }); + describe("EDIT_STATE action", () => { + it("default calls editState with correct args", () => { + handler.handleMessage(msg.EDIT_STATE, { property: "value" }); + assert.calledWith(config.router.editState, "property", "value"); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterPreferences.test.js b/browser/components/asrouter/tests/unit/ASRouterPreferences.test.js new file mode 100644 index 0000000000..a3fe1fc5c9 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterPreferences.test.js @@ -0,0 +1,480 @@ +import { + _ASRouterPreferences, + ASRouterPreferences as ASRouterPreferencesSingleton, + TEST_PROVIDERS, +} from "modules/ASRouterPreferences.sys.mjs"; +const FAKE_PROVIDERS = [{ id: "foo" }, { id: "bar" }]; + +const PROVIDER_PREF_BRANCH = + "browser.newtabpage.activity-stream.asrouter.providers."; +const DEVTOOLS_PREF = + "browser.newtabpage.activity-stream.asrouter.devtoolsEnabled"; +const CFR_USER_PREF_ADDONS = + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons"; +const CFR_USER_PREF_FEATURES = + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features"; + +/** NUMBER_OF_PREFS_TO_OBSERVE includes: + * 1. asrouter.providers. pref branch + * 2. asrouter.devtoolsEnabled + * 3. browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons (user preference - cfr) + * 4. browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features (user preference - cfr) + * 5. services.sync.username + */ +const NUMBER_OF_PREFS_TO_OBSERVE = 5; + +describe("ASRouterPreferences", () => { + let ASRouterPreferences; + let sandbox; + let addObserverStub; + let stringPrefStub; + let boolPrefStub; + let resetStub; + let hasUserValueStub; + let childListStub; + let setStringPrefStub; + + beforeEach(() => { + ASRouterPreferences = new _ASRouterPreferences(); + + sandbox = sinon.createSandbox(); + addObserverStub = sandbox.stub(global.Services.prefs, "addObserver"); + stringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref"); + resetStub = sandbox.stub(global.Services.prefs, "clearUserPref"); + setStringPrefStub = sandbox.stub(global.Services.prefs, "setStringPref"); + FAKE_PROVIDERS.forEach(provider => { + stringPrefStub + .withArgs(`${PROVIDER_PREF_BRANCH}${provider.id}`) + .returns(JSON.stringify(provider)); + }); + + boolPrefStub = sandbox + .stub(global.Services.prefs, "getBoolPref") + .returns(false); + + hasUserValueStub = sandbox + .stub(global.Services.prefs, "prefHasUserValue") + .returns(false); + + childListStub = sandbox.stub(global.Services.prefs, "getChildList"); + childListStub + .withArgs(PROVIDER_PREF_BRANCH) + .returns( + FAKE_PROVIDERS.map(provider => `${PROVIDER_PREF_BRANCH}${provider.id}`) + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + function getPrefNameForProvider(providerId) { + return `${PROVIDER_PREF_BRANCH}${providerId}`; + } + + function setPrefForProvider(providerId, value) { + stringPrefStub + .withArgs(getPrefNameForProvider(providerId)) + .returns(JSON.stringify(value)); + } + + it("ASRouterPreferences should be an instance of _ASRouterPreferences", () => { + assert.instanceOf(ASRouterPreferencesSingleton, _ASRouterPreferences); + }); + describe("#init", () => { + it("should set ._initialized to true", () => { + ASRouterPreferences.init(); + assert.isTrue(ASRouterPreferences._initialized); + }); + it("should migrate the provider prefs", () => { + ASRouterPreferences.uninit(); + // Should be migrated because they contain bucket and not collection + const MIGRATE_PROVIDERS = [ + { id: "baz", bucket: "buk" }, + { id: "qux", bucket: "buk" }, + ]; + // Should be cleared to defaults because it throws on setStringPref + const ERROR_PROVIDER = { id: "err", bucket: "buk" }; + // Should not be migrated because, although modified, it lacks bucket + const MODIFIED_SAFE_PROVIDER = { id: "safe" }; + const ALL_PROVIDERS = [ + ...MIGRATE_PROVIDERS, + ...FAKE_PROVIDERS, // Should not be migrated because they're unmodified + MODIFIED_SAFE_PROVIDER, + ERROR_PROVIDER, + ]; + // The migrator should attempt to read prefs for all of these providers + const TRY_PROVIDERS = [ + ...MIGRATE_PROVIDERS, + MODIFIED_SAFE_PROVIDER, + ERROR_PROVIDER, + ]; + + // Update the full list of provider prefs + childListStub + .withArgs(PROVIDER_PREF_BRANCH) + .returns( + ALL_PROVIDERS.map(provider => getPrefNameForProvider(provider.id)) + ); + // Stub the pref values so the migrator can read them + ALL_PROVIDERS.forEach(provider => { + stringPrefStub + .withArgs(getPrefNameForProvider(provider.id)) + .returns(JSON.stringify(provider)); + }); + + // Consider these providers' prefs "modified" + TRY_PROVIDERS.forEach(provider => { + hasUserValueStub + .withArgs(`${PROVIDER_PREF_BRANCH}${provider.id}`) + .returns(true); + }); + // Spoof an error when trying to set the pref for this provider so we can + // test that the pref is gracefully reset on error + setStringPrefStub + .withArgs(getPrefNameForProvider(ERROR_PROVIDER.id)) + .throws(); + + ASRouterPreferences.init(); + + // The migrator should have tried to check each pref for user modification + ALL_PROVIDERS.forEach(provider => + assert.calledWith(hasUserValueStub, getPrefNameForProvider(provider.id)) + ); + // Test that we don't call getStringPref for providers that don't have a + // user-defined value + FAKE_PROVIDERS.forEach(provider => + assert.neverCalledWith( + stringPrefStub, + getPrefNameForProvider(provider.id) + ) + ); + // But we do call it for providers that do have a user-defined value + TRY_PROVIDERS.forEach(provider => + assert.calledWith(stringPrefStub, getPrefNameForProvider(provider.id)) + ); + + // Test that we don't call setStringPref to migrate providers that don't + // have a bucket property + assert.neverCalledWith( + setStringPrefStub, + getPrefNameForProvider(MODIFIED_SAFE_PROVIDER.id) + ); + + /** + * For a given provider, return a sinon matcher that matches if the value + * looks like a migrated version of the original provider. Requires that: + * its id matches the original provider's id; it has no bucket; and its + * collection is set to the value of the original provider's bucket. + * @param {object} provider the provider object to compare to + * @returns {object} custom matcher object for sinon + */ + function providerJsonMatches(provider) { + return sandbox.match(migrated => { + const parsed = JSON.parse(migrated); + return ( + parsed.id === provider.id && + !("bucket" in parsed) && + parsed.collection === provider.bucket + ); + }); + } + + // Test that we call setStringPref to migrate providers that have a bucket + // property and don't have a collection property + MIGRATE_PROVIDERS.forEach(provider => + assert.calledWith( + setStringPrefStub, + getPrefNameForProvider(provider.id), + providerJsonMatches(provider) // Verify the migrated pref value + ) + ); + + // Test that we clear the pref for providers that throw when we try to + // read or write them + assert.calledWith(resetStub, getPrefNameForProvider(ERROR_PROVIDER.id)); + }); + it(`should set ${NUMBER_OF_PREFS_TO_OBSERVE} observers and not re-initialize if already initialized`, () => { + ASRouterPreferences.init(); + assert.callCount(addObserverStub, NUMBER_OF_PREFS_TO_OBSERVE); + ASRouterPreferences.init(); + ASRouterPreferences.init(); + assert.callCount(addObserverStub, NUMBER_OF_PREFS_TO_OBSERVE); + }); + }); + describe("#uninit", () => { + it("should set ._initialized to false", () => { + ASRouterPreferences.init(); + ASRouterPreferences.uninit(); + assert.isFalse(ASRouterPreferences._initialized); + }); + it("should clear cached values for ._initialized, .devtoolsEnabled", () => { + ASRouterPreferences.init(); + // trigger caching + // eslint-disable-next-line no-unused-vars + const result = [ + ASRouterPreferences.providers, + ASRouterPreferences.devtoolsEnabled, + ]; + assert.isNotNull( + ASRouterPreferences._providers, + "providers should not be null" + ); + assert.isNotNull( + ASRouterPreferences._devtoolsEnabled, + "devtolosEnabled should not be null" + ); + + ASRouterPreferences.uninit(); + assert.isNull(ASRouterPreferences._providers); + assert.isNull(ASRouterPreferences._devtoolsEnabled); + }); + it("should clear all listeners and remove observers (only once)", () => { + const removeStub = sandbox.stub(global.Services.prefs, "removeObserver"); + ASRouterPreferences.init(); + ASRouterPreferences.addListener(() => {}); + ASRouterPreferences.addListener(() => {}); + assert.equal(ASRouterPreferences._callbacks.size, 2); + ASRouterPreferences.uninit(); + // Tests to make sure we don't remove observers that weren't set + ASRouterPreferences.uninit(); + + assert.callCount(removeStub, NUMBER_OF_PREFS_TO_OBSERVE); + assert.calledWith(removeStub, PROVIDER_PREF_BRANCH); + assert.calledWith(removeStub, DEVTOOLS_PREF); + assert.isEmpty(ASRouterPreferences._callbacks); + }); + }); + describe(".providers", () => { + it("should return the value the first time .providers is accessed", () => { + ASRouterPreferences.init(); + + const result = ASRouterPreferences.providers; + assert.deepEqual(result, FAKE_PROVIDERS); + // once per pref + assert.calledTwice(stringPrefStub); + }); + it("should return the cached value the second time .providers is accessed", () => { + ASRouterPreferences.init(); + const [, secondCall] = [ + ASRouterPreferences.providers, + ASRouterPreferences.providers, + ]; + + assert.deepEqual(secondCall, FAKE_PROVIDERS); + // once per pref + assert.calledTwice(stringPrefStub); + }); + it("should just parse the pref each time if ASRouterPreferences hasn't been initialized yet", () => { + // Intentionally not initialized + const [firstCall, secondCall] = [ + ASRouterPreferences.providers, + ASRouterPreferences.providers, + ]; + + assert.deepEqual(firstCall, FAKE_PROVIDERS); + assert.deepEqual(secondCall, FAKE_PROVIDERS); + assert.callCount(stringPrefStub, 4); + }); + it("should skip the pref without throwing if a pref is not parsable", () => { + stringPrefStub.withArgs(`${PROVIDER_PREF_BRANCH}foo`).returns("not json"); + ASRouterPreferences.init(); + + assert.deepEqual(ASRouterPreferences.providers, [{ id: "bar" }]); + }); + it("should include TEST_PROVIDERS if devtools is turned on", () => { + boolPrefStub.withArgs(DEVTOOLS_PREF).returns(true); + ASRouterPreferences.init(); + + assert.deepEqual(ASRouterPreferences.providers, [ + ...TEST_PROVIDERS, + ...FAKE_PROVIDERS, + ]); + }); + }); + describe(".devtoolsEnabled", () => { + it("should read the pref the first time .devtoolsEnabled is accessed", () => { + ASRouterPreferences.init(); + + const result = ASRouterPreferences.devtoolsEnabled; + assert.deepEqual(result, false); + assert.calledOnce(boolPrefStub); + }); + it("should return the cached value the second time .devtoolsEnabled is accessed", () => { + ASRouterPreferences.init(); + const [, secondCall] = [ + ASRouterPreferences.devtoolsEnabled, + ASRouterPreferences.devtoolsEnabled, + ]; + + assert.deepEqual(secondCall, false); + assert.calledOnce(boolPrefStub); + }); + it("should just parse the pref each time if ASRouterPreferences hasn't been initialized yet", () => { + // Intentionally not initialized + const [firstCall, secondCall] = [ + ASRouterPreferences.devtoolsEnabled, + ASRouterPreferences.devtoolsEnabled, + ]; + + assert.deepEqual(firstCall, false); + assert.deepEqual(secondCall, false); + assert.calledTwice(boolPrefStub); + }); + }); + describe("#getAllUserPreferences", () => { + it("should return all user preferences", () => { + boolPrefStub.withArgs(CFR_USER_PREF_ADDONS).returns(false); + boolPrefStub.withArgs(CFR_USER_PREF_FEATURES).returns(true); + const result = ASRouterPreferences.getAllUserPreferences(); + assert.deepEqual(result, { + cfrAddons: false, + cfrFeatures: true, + }); + }); + }); + describe("#enableOrDisableProvider", () => { + it("should enable an existing provider if second param is true", () => { + setPrefForProvider("foo", { id: "foo", enabled: false }); + assert.isFalse(ASRouterPreferences.providers[0].enabled); + + ASRouterPreferences.enableOrDisableProvider("foo", true); + + assert.calledWith( + setStringPrefStub, + getPrefNameForProvider("foo"), + JSON.stringify({ id: "foo", enabled: true }) + ); + }); + it("should disable an existing provider if second param is false", () => { + setPrefForProvider("foo", { id: "foo", enabled: true }); + assert.isTrue(ASRouterPreferences.providers[0].enabled); + + ASRouterPreferences.enableOrDisableProvider("foo", false); + + assert.calledWith( + setStringPrefStub, + getPrefNameForProvider("foo"), + JSON.stringify({ id: "foo", enabled: false }) + ); + }); + it("should not throw if the id does not exist", () => { + assert.doesNotThrow(() => { + ASRouterPreferences.enableOrDisableProvider("does_not_exist", true); + }); + }); + it("should not throw if pref is not parseable", () => { + stringPrefStub + .withArgs(getPrefNameForProvider("foo")) + .returns("not valid"); + assert.doesNotThrow(() => { + ASRouterPreferences.enableOrDisableProvider("foo", true); + }); + }); + }); + describe("#setUserPreference", () => { + it("should do nothing if the pref doesn't exist", () => { + ASRouterPreferences.setUserPreference("foo", true); + assert.notCalled(boolPrefStub); + }); + it("should set the given pref", () => { + const setStub = sandbox.stub(global.Services.prefs, "setBoolPref"); + ASRouterPreferences.setUserPreference("cfrAddons", true); + assert.calledWith(setStub, CFR_USER_PREF_ADDONS, true); + }); + }); + describe("#resetProviderPref", () => { + it("should reset the pref and user prefs", () => { + ASRouterPreferences.resetProviderPref(); + FAKE_PROVIDERS.forEach(provider => { + assert.calledWith(resetStub, getPrefNameForProvider(provider.id)); + }); + assert.calledWith(resetStub, CFR_USER_PREF_ADDONS); + assert.calledWith(resetStub, CFR_USER_PREF_FEATURES); + }); + }); + describe("observer, listeners", () => { + it("should invalidate .providers when the pref is changed", () => { + const testProvider = { id: "newstuff" }; + const newProviders = [...FAKE_PROVIDERS, testProvider]; + + ASRouterPreferences.init(); + + assert.deepEqual(ASRouterPreferences.providers, FAKE_PROVIDERS); + stringPrefStub + .withArgs(getPrefNameForProvider(testProvider.id)) + .returns(JSON.stringify(testProvider)); + childListStub + .withArgs(PROVIDER_PREF_BRANCH) + .returns( + newProviders.map(provider => getPrefNameForProvider(provider.id)) + ); + ASRouterPreferences.observe( + null, + null, + getPrefNameForProvider(testProvider.id) + ); + + // Cache should be invalidated so we access the new value of the pref now + assert.deepEqual(ASRouterPreferences.providers, newProviders); + }); + it("should invalidate .devtoolsEnabled and .providers when the pref is changed", () => { + ASRouterPreferences.init(); + + assert.isFalse(ASRouterPreferences.devtoolsEnabled); + boolPrefStub.withArgs(DEVTOOLS_PREF).returns(true); + childListStub.withArgs(PROVIDER_PREF_BRANCH).returns([]); + ASRouterPreferences.observe(null, null, DEVTOOLS_PREF); + + // Cache should be invalidated so we access the new value of the pref now + // Note that providers needs to be invalidated because devtools adds test content to it. + assert.isTrue(ASRouterPreferences.devtoolsEnabled); + assert.deepEqual(ASRouterPreferences.providers, TEST_PROVIDERS); + }); + it("should call listeners added with .addListener", () => { + const callback1 = sinon.stub(); + const callback2 = sinon.stub(); + ASRouterPreferences.init(); + ASRouterPreferences.addListener(callback1); + ASRouterPreferences.addListener(callback2); + + ASRouterPreferences.observe(null, null, getPrefNameForProvider("foo")); + assert.calledWith(callback1, getPrefNameForProvider("foo")); + + ASRouterPreferences.observe(null, null, DEVTOOLS_PREF); + assert.calledWith(callback2, DEVTOOLS_PREF); + }); + it("should not call listeners after they are removed with .removeListeners", () => { + const callback = sinon.stub(); + ASRouterPreferences.init(); + ASRouterPreferences.addListener(callback); + + ASRouterPreferences.observe(null, null, getPrefNameForProvider("foo")); + assert.calledWith(callback, getPrefNameForProvider("foo")); + + callback.reset(); + ASRouterPreferences.removeListener(callback); + + ASRouterPreferences.observe(null, null, DEVTOOLS_PREF); + assert.notCalled(callback); + }); + }); + describe("#_transformPersonalizedCfrScores", () => { + it("should report JSON.parse errors", () => { + sandbox.stub(global.console, "error"); + + ASRouterPreferences._transformPersonalizedCfrScores(""); + + assert.calledOnce(global.console.error); + }); + it("should return an object parsed from a string", () => { + const scores = { FOO: 3000, BAR: 4000 }; + assert.deepEqual( + ASRouterPreferences._transformPersonalizedCfrScores( + JSON.stringify(scores) + ), + scores + ); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterTargeting.test.js b/browser/components/asrouter/tests/unit/ASRouterTargeting.test.js new file mode 100644 index 0000000000..610b488b47 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterTargeting.test.js @@ -0,0 +1,574 @@ +import { + ASRouterTargeting, + CachedTargetingGetter, + getSortedMessages, + QueryCache, +} from "modules/ASRouterTargeting.sys.mjs"; +import { OnboardingMessageProvider } from "modules/OnboardingMessageProvider.sys.mjs"; +import { ASRouterPreferences } from "modules/ASRouterPreferences.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +// Note that tests for the ASRouterTargeting environment can be found in +// test/functional/mochitest/browser_asrouter_targeting.js + +describe("#CachedTargetingGetter", () => { + const sixHours = 6 * 60 * 60 * 1000; + let sandbox; + let clock; + let frecentStub; + let topsitesCache; + let globals; + let doesAppNeedPinStub; + let getAddonsByTypesStub; + beforeEach(() => { + sandbox = sinon.createSandbox(); + clock = sinon.useFakeTimers(); + frecentStub = sandbox.stub( + global.NewTabUtils.activityStreamProvider, + "getTopFrecentSites" + ); + topsitesCache = new CachedTargetingGetter("getTopFrecentSites"); + globals = new GlobalOverrider(); + globals.set( + "TargetingContext", + class { + static combineContexts(...args) { + return sinon.stub(); + } + + evalWithDefault(expr) { + return sinon.stub(); + } + } + ); + doesAppNeedPinStub = sandbox.stub().resolves(); + getAddonsByTypesStub = sandbox.stub().resolves(); + }); + + afterEach(() => { + sandbox.restore(); + clock.restore(); + globals.restore(); + }); + + it("should cache allow for optional getter argument", async () => { + let pinCachedGetter = new CachedTargetingGetter( + "doesAppNeedPin", + true, + undefined, + { doesAppNeedPin: doesAppNeedPinStub } + ); + // Need to tick forward because Date.now() is stubbed + clock.tick(sixHours); + + await pinCachedGetter.get(); + await pinCachedGetter.get(); + await pinCachedGetter.get(); + + // Called once; cached request + assert.calledOnce(doesAppNeedPinStub); + + // Called with option argument + assert.calledWith(doesAppNeedPinStub, true); + + // Expire and call again + clock.tick(sixHours); + await pinCachedGetter.get(); + + // Call goes through + assert.calledTwice(doesAppNeedPinStub); + + let themesCachedGetter = new CachedTargetingGetter( + "getAddonsByTypes", + ["foo"], + undefined, + { getAddonsByTypes: getAddonsByTypesStub } + ); + + // Need to tick forward because Date.now() is stubbed + clock.tick(sixHours); + + await themesCachedGetter.get(); + await themesCachedGetter.get(); + await themesCachedGetter.get(); + + // Called once; cached request + assert.calledOnce(getAddonsByTypesStub); + + // Called with option argument + assert.calledWith(getAddonsByTypesStub, ["foo"]); + + // Expire and call again + clock.tick(sixHours); + await themesCachedGetter.get(); + + // Call goes through + assert.calledTwice(getAddonsByTypesStub); + }); + + it("should only make a request every 6 hours", async () => { + frecentStub.resolves(); + clock.tick(sixHours); + + await topsitesCache.get(); + await topsitesCache.get(); + + assert.calledOnce( + global.NewTabUtils.activityStreamProvider.getTopFrecentSites + ); + + clock.tick(sixHours); + + await topsitesCache.get(); + + assert.calledTwice( + global.NewTabUtils.activityStreamProvider.getTopFrecentSites + ); + }); + it("throws when failing getter", async () => { + frecentStub.rejects(new Error("fake error")); + clock.tick(sixHours); + + // assert.throws expect a function as the first parameter, try/catch is a + // workaround + let rejected = false; + try { + await topsitesCache.get(); + } catch (e) { + rejected = true; + } + + assert(rejected); + }); + describe("sortMessagesByPriority", () => { + it("should sort messages in descending priority order", async () => { + const [m1, m2, m3 = { id: "m3" }] = + await OnboardingMessageProvider.getUntranslatedMessages(); + const checkMessageTargetingStub = sandbox + .stub(ASRouterTargeting, "checkMessageTargeting") + .resolves(false); + sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); + + await ASRouterTargeting.findMatchingMessage({ + messages: [ + { ...m1, priority: 0 }, + { ...m2, priority: 1 }, + { ...m3, priority: 2 }, + ], + trigger: "testing", + }); + + assert.equal(checkMessageTargetingStub.callCount, 3); + + const [arg_m1] = checkMessageTargetingStub.firstCall.args; + assert.equal(arg_m1.id, m3.id); + + const [arg_m2] = checkMessageTargetingStub.secondCall.args; + assert.equal(arg_m2.id, m2.id); + + const [arg_m3] = checkMessageTargetingStub.thirdCall.args; + assert.equal(arg_m3.id, m1.id); + }); + it("should sort messages with no priority last", async () => { + const [m1, m2, m3 = { id: "m3" }] = + await OnboardingMessageProvider.getUntranslatedMessages(); + const checkMessageTargetingStub = sandbox + .stub(ASRouterTargeting, "checkMessageTargeting") + .resolves(false); + sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); + + await ASRouterTargeting.findMatchingMessage({ + messages: [ + { ...m1, priority: 0 }, + { ...m2, priority: undefined }, + { ...m3, priority: 2 }, + ], + trigger: "testing", + }); + + assert.equal(checkMessageTargetingStub.callCount, 3); + + const [arg_m1] = checkMessageTargetingStub.firstCall.args; + assert.equal(arg_m1.id, m3.id); + + const [arg_m2] = checkMessageTargetingStub.secondCall.args; + assert.equal(arg_m2.id, m1.id); + + const [arg_m3] = checkMessageTargetingStub.thirdCall.args; + assert.equal(arg_m3.id, m2.id); + }); + it("should keep the order of messages with same priority unchanged", async () => { + const [m1, m2, m3 = { id: "m3" }] = + await OnboardingMessageProvider.getUntranslatedMessages(); + const checkMessageTargetingStub = sandbox + .stub(ASRouterTargeting, "checkMessageTargeting") + .resolves(false); + sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); + + await ASRouterTargeting.findMatchingMessage({ + messages: [ + { ...m1, priority: 2, targeting: undefined, rank: 1 }, + { ...m2, priority: undefined, targeting: undefined, rank: 1 }, + { ...m3, priority: 2, targeting: undefined, rank: 1 }, + ], + trigger: "testing", + }); + + assert.equal(checkMessageTargetingStub.callCount, 3); + + const [arg_m1] = checkMessageTargetingStub.firstCall.args; + assert.equal(arg_m1.id, m1.id); + + const [arg_m2] = checkMessageTargetingStub.secondCall.args; + assert.equal(arg_m2.id, m3.id); + + const [arg_m3] = checkMessageTargetingStub.thirdCall.args; + assert.equal(arg_m3.id, m2.id); + }); + }); +}); +describe("#isTriggerMatch", () => { + let trigger; + let message; + beforeEach(() => { + trigger = { id: "openURL" }; + message = { id: "openURL" }; + }); + it("should return false if trigger and candidate ids are different", () => { + trigger.id = "trigger"; + message.id = "message"; + + assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message)); + assert.isTrue( + ASRouterTargeting.isTriggerMatch({ id: "foo" }, { id: "foo" }) + ); + }); + it("should return true if the message we check doesn't have trigger params or patterns", () => { + // No params or patterns defined + assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message)); + }); + it("should return false if the trigger does not have params defined", () => { + message.params = {}; + + // trigger.param is undefined + assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message)); + }); + it("should return true if message params includes trigger host", () => { + message.params = ["mozilla.org"]; + trigger.param = { host: "mozilla.org" }; + + assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message)); + }); + it("should return true if message params includes trigger param.type", () => { + message.params = ["ContentBlockingMilestone"]; + trigger.param = { type: "ContentBlockingMilestone" }; + + assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message))); + }); + it("should return true if message params match trigger mask", () => { + // STATE_BLOCKED_FINGERPRINTING_CONTENT + message.params = [0x00000040]; + trigger.param = { type: 538091584 }; + + assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message))); + }); +}); +describe("#CacheListAttachedOAuthClients", () => { + const fourHours = 4 * 60 * 60 * 1000; + let sandbox; + let clock; + let fakeFxAccount; + let authClientsCache; + let globals; + + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + clock = sinon.useFakeTimers(); + fakeFxAccount = { + listAttachedOAuthClients: () => {}, + }; + globals.set("fxAccounts", fakeFxAccount); + authClientsCache = QueryCache.queries.ListAttachedOAuthClients; + sandbox + .stub(global.fxAccounts, "listAttachedOAuthClients") + .returns(Promise.resolve({})); + }); + + afterEach(() => { + authClientsCache.expire(); + sandbox.restore(); + clock.restore(); + }); + + it("should only make additional request every 4 hours", async () => { + clock.tick(fourHours); + + await authClientsCache.get(); + assert.calledOnce(global.fxAccounts.listAttachedOAuthClients); + + clock.tick(fourHours); + await authClientsCache.get(); + assert.calledTwice(global.fxAccounts.listAttachedOAuthClients); + }); + + it("should not make additional request before 4 hours", async () => { + clock.tick(fourHours); + + await authClientsCache.get(); + assert.calledOnce(global.fxAccounts.listAttachedOAuthClients); + + await authClientsCache.get(); + assert.calledOnce(global.fxAccounts.listAttachedOAuthClients); + }); +}); +describe("ASRouterTargeting", () => { + let evalStub; + let sandbox; + let clock; + let globals; + let fakeTargetingContext; + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.replace(ASRouterTargeting, "Environment", {}); + clock = sinon.useFakeTimers(); + fakeTargetingContext = { + combineContexts: sandbox.stub(), + evalWithDefault: sandbox.stub().resolves(), + setTelemetrySource: sandbox.stub(), + }; + globals = new GlobalOverrider(); + globals.set( + "TargetingContext", + class { + static combineContexts(...args) { + return fakeTargetingContext.combineContexts.apply(sandbox, args); + } + + setTelemetrySource(id) { + fakeTargetingContext.setTelemetrySource(id); + } + + evalWithDefault(expr) { + return fakeTargetingContext.evalWithDefault(expr); + } + } + ); + evalStub = fakeTargetingContext.evalWithDefault; + }); + afterEach(() => { + clock.restore(); + sandbox.restore(); + globals.restore(); + }); + it("should provide message.id as source", async () => { + await ASRouterTargeting.checkMessageTargeting( + { + id: "message", + targeting: "true", + }, + fakeTargetingContext, + sandbox.stub(), + false + ); + assert.calledOnce(fakeTargetingContext.evalWithDefault); + assert.calledWithExactly(fakeTargetingContext.evalWithDefault, "true"); + assert.calledWithExactly( + fakeTargetingContext.setTelemetrySource, + "message" + ); + }); + it("should cache evaluation result", async () => { + evalStub.resolves(true); + let targetingContext = new global.TargetingContext(); + + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl1" }, + targetingContext, + sandbox.stub(), + true + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl2" }, + targetingContext, + sandbox.stub(), + true + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl1" }, + targetingContext, + sandbox.stub(), + true + ); + + assert.calledTwice(evalStub); + }); + it("should not cache evaluation result", async () => { + evalStub.resolves(true); + let targetingContext = new global.TargetingContext(); + + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + false + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + false + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + false + ); + + assert.calledThrice(evalStub); + }); + it("should expire cache entries", async () => { + evalStub.resolves(true); + let targetingContext = new global.TargetingContext(); + + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + true + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + true + ); + clock.tick(5 * 60 * 1000 + 1); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + true + ); + + assert.calledTwice(evalStub); + }); + + describe("#findMatchingMessage", () => { + let matchStub; + let messages = [ + { id: "FOO", targeting: "match" }, + { id: "BAR", targeting: "match" }, + { id: "BAZ" }, + ]; + beforeEach(() => { + matchStub = sandbox + .stub(ASRouterTargeting, "_isMessageMatch") + .callsFake(message => message.targeting === "match"); + }); + it("should return an array of matches if returnAll is true", async () => { + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + returnAll: true, + }), + [ + { id: "FOO", targeting: "match" }, + { id: "BAR", targeting: "match" }, + ] + ); + }); + it("should return an empty array if no matches were found and returnAll is true", async () => { + matchStub.returns(false); + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + returnAll: true, + }), + [] + ); + }); + it("should return the first match if returnAll is false", async () => { + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + }), + messages[0] + ); + }); + it("should return null if if no matches were found and returnAll is false", async () => { + matchStub.returns(false); + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + }), + null + ); + }); + }); +}); + +/** + * Messages should be sorted in the following order: + * 1. Rank + * 2. Priority + * 3. If the message has targeting + * 4. Order or randomization, depending on input + */ +describe("getSortedMessages", () => { + let globals = new GlobalOverrider(); + let sandbox; + beforeEach(() => { + globals.set({ ASRouterPreferences }); + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + /** + * assertSortsCorrectly - Tests to see if an array, when sorted with getSortedMessages, + * returns the items in the expected order. + * + * @param {Message[]} expectedOrderArray - The array of messages in its expected order + * @param {{}} options - The options param for getSortedMessages + * @returns + */ + function assertSortsCorrectly(expectedOrderArray, options) { + const input = [...expectedOrderArray].reverse(); + const result = getSortedMessages(input, options); + const indexes = result.map(message => expectedOrderArray.indexOf(message)); + return assert.equal( + indexes.join(","), + [...expectedOrderArray.keys()].join(","), + "Messsages are out of order" + ); + } + + it("should sort messages by priority, then by targeting", () => { + assertSortsCorrectly([ + { priority: 100, targeting: "isFoo" }, + { priority: 100 }, + { priority: 99 }, + { priority: 1, targeting: "isFoo" }, + { priority: 1 }, + {}, + ]); + }); + it("should sort messages by priority, then targeting, then order if ordered param is true", () => { + assertSortsCorrectly( + [ + { priority: 100, order: 4 }, + { priority: 100, order: 5 }, + { priority: 1, order: 3, targeting: "isFoo" }, + { priority: 1, order: 0 }, + { priority: 1, order: 1 }, + { priority: 1, order: 2 }, + { order: 0 }, + ], + { ordered: true } + ); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterTriggerListeners.test.js b/browser/components/asrouter/tests/unit/ASRouterTriggerListeners.test.js new file mode 100644 index 0000000000..aa455e23a2 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterTriggerListeners.test.js @@ -0,0 +1,833 @@ +import { ASRouterTriggerListeners } from "modules/ASRouterTriggerListeners.sys.mjs"; +import { ASRouterPreferences } from "modules/ASRouterPreferences.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("ASRouterTriggerListeners", () => { + let sandbox; + let globals; + let existingWindow; + let isWindowPrivateStub; + const triggerHandler = () => {}; + const openURLListener = ASRouterTriggerListeners.get("openURL"); + const frequentVisitsListener = ASRouterTriggerListeners.get("frequentVisits"); + const captivePortalLoginListener = + ASRouterTriggerListeners.get("captivePortalLogin"); + const bookmarkedURLListener = + ASRouterTriggerListeners.get("openBookmarkedURL"); + const openArticleURLListener = ASRouterTriggerListeners.get("openArticleURL"); + const nthTabClosedListener = ASRouterTriggerListeners.get("nthTabClosed"); + const idleListener = ASRouterTriggerListeners.get("activityAfterIdle"); + const formAutofillListener = ASRouterTriggerListeners.get("formAutofill"); + const cookieBannerDetectedListener = ASRouterTriggerListeners.get( + "cookieBannerDetected" + ); + const cookieBannerHandledListener = ASRouterTriggerListeners.get( + "cookieBannerHandled" + ); + const hosts = ["www.mozilla.com", "www.mozilla.org"]; + + const regionFake = { + _home: "cn", + _current: "cn", + get home() { + return this._home; + }, + get current() { + return this._current; + }, + }; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + existingWindow = { + gBrowser: { + addTabsProgressListener: sandbox.stub(), + removeTabsProgressListener: sandbox.stub(), + currentURI: { host: "" }, + }, + addEventListener: sinon.stub(), + removeEventListener: sinon.stub(), + }; + sandbox.spy(openURLListener, "init"); + sandbox.spy(openURLListener, "uninit"); + isWindowPrivateStub = sandbox.stub(); + // Assume no window is private so that we execute the action + isWindowPrivateStub.returns(false); + globals.set("PrivateBrowsingUtils", { + isWindowPrivate: isWindowPrivateStub, + }); + const ewUninit = new Map(); + globals.set("EveryWindow", { + registerCallback: (id, init, uninit) => { + init(existingWindow); + ewUninit.set(id, uninit); + }, + unregisterCallback: id => { + ewUninit.get(id)(existingWindow); + }, + }); + globals.set("Region", regionFake); + globals.set("ASRouterPreferences", ASRouterPreferences); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + describe("openBookmarkedURL", () => { + let observerStub; + describe("#init", () => { + beforeEach(() => { + observerStub = sandbox.stub(global.Services.obs, "addObserver"); + sandbox + .stub(global.Services.wm, "getMostRecentBrowserWindow") + .returns({ gBrowser: { selectedBrowser: {} } }); + }); + afterEach(() => { + bookmarkedURLListener.uninit(); + }); + it("should set hosts to the recentBookmarks", async () => { + await bookmarkedURLListener.init(sandbox.stub()); + + assert.calledOnce(observerStub); + assert.calledWithExactly( + observerStub, + bookmarkedURLListener, + "bookmark-icon-updated" + ); + }); + it("should provide id to triggerHandler", async () => { + const newTriggerHandler = sinon.stub(); + const subject = {}; + await bookmarkedURLListener.init(newTriggerHandler); + + bookmarkedURLListener.observe( + subject, + "bookmark-icon-updated", + "starred" + ); + + assert.calledOnce(newTriggerHandler); + assert.calledWithExactly(newTriggerHandler, subject, { + id: bookmarkedURLListener.id, + }); + }); + }); + }); + + describe("captivePortal", () => { + describe("observe", () => { + it("should not call the trigger handler if _shouldShowCaptivePortalVPNPromo returns false", () => { + sandbox + .stub(captivePortalLoginListener, "_shouldShowCaptivePortalVPNPromo") + .returns(false); + captivePortalLoginListener._triggerHandler = sandbox.spy(); + + captivePortalLoginListener.observe( + null, + "captive-portal-login-success" + ); + + assert.notCalled(captivePortalLoginListener._triggerHandler); + }); + + it("should call the trigger handler if _shouldShowCaptivePortalVPNPromo returns true", () => { + sandbox + .stub(captivePortalLoginListener, "_shouldShowCaptivePortalVPNPromo") + .returns(true); + sandbox.stub(Services.wm, "getMostRecentBrowserWindow").returns({ + gBrowser: { + selectedBrowser: true, + }, + }); + captivePortalLoginListener._triggerHandler = sandbox.spy(); + + captivePortalLoginListener.observe( + null, + "captive-portal-login-success" + ); + + assert.calledOnce(captivePortalLoginListener._triggerHandler); + }); + }); + }); + + describe("openArticleURL", () => { + describe("#init", () => { + beforeEach(() => { + globals.set( + "MatchPatternSet", + sandbox.stub().callsFake(patterns => ({ + patterns, + matches: url => patterns.has(url), + })) + ); + sandbox.stub(global.AboutReaderParent, "addMessageListener"); + sandbox.stub(global.AboutReaderParent, "removeMessageListener"); + }); + afterEach(() => { + openArticleURLListener.uninit(); + }); + it("setup an event listener on init", () => { + openArticleURLListener.init(sandbox.stub(), hosts, hosts); + + assert.calledOnce(global.AboutReaderParent.addMessageListener); + assert.calledWithExactly( + global.AboutReaderParent.addMessageListener, + openArticleURLListener.readerModeEvent, + sinon.match.object + ); + }); + it("should call triggerHandler correctly for matches [host match]", () => { + const stub = sandbox.stub(); + const target = { currentURI: { host: hosts[0], spec: hosts[1] } }; + openArticleURLListener.init(stub, hosts, hosts); + + const [, { receiveMessage }] = + global.AboutReaderParent.addMessageListener.firstCall.args; + receiveMessage({ data: { isArticle: true }, target }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, target, { + id: openArticleURLListener.id, + param: { host: hosts[0], url: hosts[1] }, + }); + }); + it("should call triggerHandler correctly for matches [pattern match]", () => { + const stub = sandbox.stub(); + const target = { currentURI: { host: null, spec: hosts[1] } }; + openArticleURLListener.init(stub, hosts, hosts); + + const [, { receiveMessage }] = + global.AboutReaderParent.addMessageListener.firstCall.args; + receiveMessage({ data: { isArticle: true }, target }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, target, { + id: openArticleURLListener.id, + param: { host: null, url: hosts[1] }, + }); + }); + it("should remove the message listener", () => { + openArticleURLListener.init(sandbox.stub(), hosts, hosts); + openArticleURLListener.uninit(); + + assert.calledOnce(global.AboutReaderParent.removeMessageListener); + }); + }); + }); + + describe("frequentVisits", () => { + let _triggerHandler; + beforeEach(() => { + _triggerHandler = sandbox.stub(); + sandbox.useFakeTimers(); + frequentVisitsListener.init(_triggerHandler, hosts); + }); + afterEach(() => { + sandbox.clock.restore(); + frequentVisitsListener.uninit(); + }); + it("should be initialized", () => { + assert.isTrue(frequentVisitsListener._initialized); + }); + it("should listen for TabSelect events", () => { + assert.calledOnce(existingWindow.addEventListener); + assert.calledWith( + existingWindow.addEventListener, + "TabSelect", + frequentVisitsListener.onTabSwitch + ); + }); + it("should call _triggerHandler if the visit is valid (is recoreded)", () => { + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + + assert.calledOnce(_triggerHandler); + }); + it("should call _triggerHandler only once", () => { + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + + assert.calledOnce(_triggerHandler); + }); + it("should call _triggerHandler again after 15 minutes", () => { + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + sandbox.clock.tick(15 * 60 * 1000 + 1); + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + + assert.calledTwice(_triggerHandler); + }); + it("should call triggerHandler on valid hosts", () => { + const stub = sandbox.stub(frequentVisitsListener, "triggerHandler"); + existingWindow.gBrowser.currentURI.host = hosts[0]; // eslint-disable-line prefer-destructuring + + frequentVisitsListener.onTabSwitch({ + target: { ownerGlobal: existingWindow }, + }); + + assert.calledOnce(stub); + }); + it("should not call triggerHandler on invalid hosts", () => { + const stub = sandbox.stub(frequentVisitsListener, "triggerHandler"); + existingWindow.gBrowser.currentURI.host = "foo.com"; + + frequentVisitsListener.onTabSwitch({ + target: { ownerGlobal: existingWindow }, + }); + + assert.notCalled(stub); + }); + describe("MatchPattern", () => { + beforeEach(() => { + globals.set( + "MatchPatternSet", + sandbox.stub().callsFake(patterns => ({ patterns: patterns || [] })) + ); + }); + afterEach(() => { + frequentVisitsListener.uninit(); + }); + it("should create a matchPatternSet", () => { + frequentVisitsListener.init(_triggerHandler, hosts, ["pattern"]); + + assert.calledOnce(window.MatchPatternSet); + assert.calledWithExactly( + window.MatchPatternSet, + new Set(["pattern"]), + undefined + ); + }); + it("should allow to add multiple patterns and dedupe", () => { + frequentVisitsListener.init(_triggerHandler, hosts, ["pattern"]); + frequentVisitsListener.init(_triggerHandler, hosts, ["foo"]); + + assert.calledTwice(window.MatchPatternSet); + assert.calledWithExactly( + window.MatchPatternSet, + new Set(["pattern", "foo"]), + undefined + ); + }); + it("should handle bad arguments to MatchPatternSet", () => { + const badArgs = ["www.example.com"]; + window.MatchPatternSet.withArgs(new Set(badArgs)).throws(); + frequentVisitsListener.init(_triggerHandler, hosts, badArgs); + + // Fails with an empty MatchPatternSet + assert.property(frequentVisitsListener._matchPatternSet, "patterns"); + + // Second try is succesful + frequentVisitsListener.init(_triggerHandler, hosts, ["foo"]); + + assert.property(frequentVisitsListener._matchPatternSet, "patterns"); + assert.isTrue( + frequentVisitsListener._matchPatternSet.patterns.has("foo") + ); + }); + }); + }); + + describe("nthTabClosed", () => { + describe("#init", () => { + beforeEach(() => { + nthTabClosedListener.init(triggerHandler); + }); + afterEach(() => { + nthTabClosedListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(nthTabClosedListener._initialized); + assert.equal(nthTabClosedListener._triggerHandler, triggerHandler); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + nthTabClosedListener.init(newTriggerHandler); + assert.ok(nthTabClosedListener._initialized); + assert.equal(nthTabClosedListener._triggerHandler, newTriggerHandler); + }); + + it("should add an event listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.addEventListener); + assert.calledWith(existingWindow.addEventListener, "TabClose"); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + nthTabClosedListener.init(triggerHandler); + nthTabClosedListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler, closed tabs count", () => { + assert.notOk(nthTabClosedListener._initialized); + assert.equal(nthTabClosedListener._triggerHandler, null); + assert.equal(nthTabClosedListener._closedTabs, 0); + }); + + it("should do nothing if already uninitialised", () => { + nthTabClosedListener.uninit(); + assert.notOk(nthTabClosedListener._initialized); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.calledOnce(existingWindow.removeEventListener); + }); + }); + }); + + describe("activityAfterIdle", () => { + let addObsStub; + let removeObsStub; + describe("#init", () => { + beforeEach(() => { + addObsStub = sandbox.stub(global.Services.obs, "addObserver"); + sandbox + .stub(global.Services.wm, "getEnumerator") + .returns([{ closed: false, document: { hidden: false } }]); + idleListener.init(triggerHandler); + }); + afterEach(() => { + idleListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(idleListener._initialized); + assert.equal(idleListener._triggerHandler, triggerHandler); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + idleListener.init(newTriggerHandler); + assert.ok(idleListener._initialized); + assert.equal(idleListener._triggerHandler, newTriggerHandler); + }); + + it("should add observers for idle and activity", () => { + assert.called(addObsStub); + }); + + it("should add event listeners to all existing browser windows", () => { + assert.called(existingWindow.addEventListener); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + removeObsStub = sandbox.stub(global.Services.obs, "removeObserver"); + sandbox.stub(global.Services.wm, "getEnumerator").returns([]); + idleListener.init(triggerHandler); + idleListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler and timestamps", () => { + assert.notOk(idleListener._initialized); + assert.equal(idleListener._triggerHandler, null); + assert.equal(idleListener._quietSince, null); + }); + + it("should do nothing if already uninitialised", () => { + idleListener.uninit(); + assert.notOk(idleListener._initialized); + }); + + it("should remove observers for idle and activity", () => { + assert.called(removeObsStub); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.called(existingWindow.removeEventListener); + }); + }); + }); + + describe("formAutofill", () => { + let addObsStub; + let removeObsStub; + describe("#init", () => { + beforeEach(() => { + addObsStub = sandbox.stub(global.Services.obs, "addObserver"); + formAutofillListener.init(triggerHandler); + }); + afterEach(() => { + formAutofillListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(formAutofillListener._initialized); + assert.equal(formAutofillListener._triggerHandler, triggerHandler); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + formAutofillListener.init(newTriggerHandler); + assert.ok(formAutofillListener._initialized); + assert.equal(formAutofillListener._triggerHandler, newTriggerHandler); + }); + + it(`should add observer for ${formAutofillListener._topic}`, () => { + assert.called(addObsStub); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + removeObsStub = sandbox.stub(global.Services.obs, "removeObserver"); + formAutofillListener.init(triggerHandler); + formAutofillListener.uninit(); + }); + + it("should set ._initialized to false and clear the triggerHandler", () => { + assert.notOk(formAutofillListener._initialized); + assert.equal(formAutofillListener._triggerHandler, null); + }); + + it("should do nothing if already uninitialised", () => { + formAutofillListener.uninit(); + assert.notOk(formAutofillListener._initialized); + }); + + it(`should remove observers for ${formAutofillListener._topic}`, () => { + assert.called(removeObsStub); + }); + }); + }); + + describe("openURL listener", () => { + it("should exist and initially be uninitialised", () => { + assert.ok(openURLListener); + assert.notOk(openURLListener._initialized); + }); + + describe("#init", () => { + beforeEach(() => { + openURLListener.init(triggerHandler, hosts); + }); + afterEach(() => { + openURLListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler and hosts", () => { + assert.ok(openURLListener._initialized); + assert.deepEqual(openURLListener._hosts, new Set(hosts)); + assert.equal(openURLListener._triggerHandler, triggerHandler); + }); + + it("should add tab progress listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.gBrowser.addTabsProgressListener); + assert.calledWithExactly( + existingWindow.gBrowser.addTabsProgressListener, + openURLListener + ); + }); + + it("if already initialised, should only update the trigger handler and add the new hosts", () => { + const newHosts = ["www.example.com"]; + const newTriggerHandler = () => {}; + existingWindow.gBrowser.addTabsProgressListener.reset(); + + openURLListener.init(newTriggerHandler, newHosts); + assert.ok(openURLListener._initialized); + assert.deepEqual( + openURLListener._hosts, + new Set([...hosts, ...newHosts]) + ); + assert.equal(openURLListener._triggerHandler, newTriggerHandler); + assert.notCalled(existingWindow.gBrowser.addTabsProgressListener); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + openURLListener.init(triggerHandler, hosts); + openURLListener.uninit(); + }); + + it("should set ._initialized to false and clear the triggerHandler and hosts", () => { + assert.notOk(openURLListener._initialized); + assert.equal(openURLListener._hosts, null); + assert.equal(openURLListener._triggerHandler, null); + }); + + it("should remove tab progress listeners from all existing browser windows", () => { + assert.calledOnce(existingWindow.gBrowser.removeTabsProgressListener); + assert.calledWithExactly( + existingWindow.gBrowser.removeTabsProgressListener, + openURLListener + ); + }); + + it("should do nothing if already uninitialised", () => { + existingWindow.gBrowser.removeTabsProgressListener.reset(); + + openURLListener.uninit(); + assert.notOk(openURLListener._initialized); + assert.notCalled(existingWindow.gBrowser.removeTabsProgressListener); + }); + }); + + describe("#onLocationChange", () => { + afterEach(() => { + openURLListener.uninit(); + frequentVisitsListener.uninit(); + }); + + it("should call the ._triggerHandler with the right arguments", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const location = "www.mozilla.org"; + openURLListener.onLocationChange(browser, webProgress, undefined, { + host: location, + spec: location, + }); + assert.calledOnce(newTriggerHandler); + assert.calledWithExactly(newTriggerHandler, browser, { + id: "openURL", + param: { host: "www.mozilla.org", url: "www.mozilla.org" }, + context: { visitsCount: 1 }, + }); + }); + it("should call triggerHandler for a redirect (openURL + frequentVisits)", () => { + for (let trigger of [openURLListener, frequentVisitsListener]) { + const newTriggerHandler = sinon.stub(); + trigger.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + trigger.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledOnce(aRequest.QueryInterface); + assert.calledOnce(newTriggerHandler); + } + }); + it("should call triggerHandler with the right arguments (redirect)", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + openURLListener.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledWithExactly(newTriggerHandler, browser, { + id: "openURL", + param: { host: "www.mozilla.org", url: "www.mozilla.org" }, + context: { visitsCount: 1 }, + }); + }); + it("should call triggerHandler for a redirect (openURL + frequentVisits)", () => { + for (let trigger of [openURLListener, frequentVisitsListener]) { + const newTriggerHandler = sinon.stub(); + trigger.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + trigger.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledOnce(aRequest.QueryInterface); + assert.calledOnce(newTriggerHandler); + } + }); + it("should call triggerHandler with the right arguments (redirect)", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + openURLListener.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledWithExactly(newTriggerHandler, browser, { + id: "openURL", + param: { host: "www.mozilla.org", url: "www.mozilla.org" }, + context: { visitsCount: 1 }, + }); + }); + it("should fail for subdomains (not redirect)", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { + spec: "subdomain.mozilla.org", + host: "subdomain.mozilla.org", + }, + }), + }; + openURLListener.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledOnce(aRequest.QueryInterface); + assert.notCalled(newTriggerHandler); + }); + }); + }); + + describe("cookieBannerDetected", () => { + describe("#init", () => { + beforeEach(() => { + cookieBannerDetectedListener.init(triggerHandler); + }); + afterEach(() => { + cookieBannerDetectedListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(cookieBannerDetectedListener._initialized); + assert.equal( + cookieBannerDetectedListener._triggerHandler, + triggerHandler + ); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + cookieBannerDetectedListener.init(newTriggerHandler); + assert.ok(cookieBannerDetectedListener._initialized); + assert.equal( + cookieBannerDetectedListener._triggerHandler, + newTriggerHandler + ); + }); + + it("should add an event listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.addEventListener); + }); + }); + describe("#uninit", () => { + beforeEach(async () => { + cookieBannerDetectedListener.init(triggerHandler); + cookieBannerDetectedListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler and timestamps", () => { + assert.notOk(cookieBannerDetectedListener._initialized); + assert.equal(cookieBannerDetectedListener._triggerHandler, null); + }); + + it("should do nothing if already uninitialised", () => { + cookieBannerDetectedListener.uninit(); + assert.notOk(cookieBannerDetectedListener._initialized); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.called(existingWindow.removeEventListener); + }); + }); + }); + + describe("cookieBannerHandled", () => { + describe("#init", () => { + beforeEach(() => { + cookieBannerHandledListener.init(triggerHandler); + }); + afterEach(() => { + cookieBannerHandledListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(cookieBannerHandledListener._initialized); + assert.equal( + cookieBannerHandledListener._triggerHandler, + triggerHandler + ); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + cookieBannerHandledListener.init(newTriggerHandler); + assert.ok(cookieBannerHandledListener._initialized); + assert.equal( + cookieBannerHandledListener._triggerHandler, + newTriggerHandler + ); + }); + + it("should add an event listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.addEventListener); + }); + }); + describe("#uninit", () => { + beforeEach(async () => { + cookieBannerHandledListener.init(triggerHandler); + cookieBannerHandledListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler and timestamps", () => { + assert.notOk(cookieBannerHandledListener._initialized); + assert.equal(cookieBannerHandledListener._triggerHandler, null); + }); + + it("should do nothing if already uninitialised", () => { + cookieBannerHandledListener.uninit(); + assert.notOk(cookieBannerHandledListener._initialized); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.called(existingWindow.removeEventListener); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js b/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js new file mode 100644 index 0000000000..fe6959852c --- /dev/null +++ b/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js @@ -0,0 +1,32 @@ +import { CFRMessageProvider } from "modules/CFRMessageProvider.sys.mjs"; + +const REGULAR_IDS = [ + "FACEBOOK_CONTAINER", + "GOOGLE_TRANSLATE", + "YOUTUBE_ENHANCE", + // These are excluded for now. + // "WIKIPEDIA_CONTEXT_MENU_SEARCH", + // "REDDIT_ENHANCEMENT", +]; + +describe("CFRMessageProvider", () => { + let messages; + beforeEach(async () => { + messages = await CFRMessageProvider.getMessages(); + }); + it("should have a total of 11 messages", () => { + assert.lengthOf(messages, 11); + }); + it("should have one message each for the three regular addons", () => { + for (const id of REGULAR_IDS) { + const cohort3 = messages.find(msg => msg.id === `${id}_3`); + assert.ok(cohort3, `contains three day cohort for ${id}`); + assert.deepEqual( + cohort3.frequency, + { lifetime: 3 }, + "three day cohort has the right frequency cap" + ); + assert.notInclude(cohort3.targeting, `providerCohorts.cfr`); + } + }); +}); diff --git a/browser/components/asrouter/tests/unit/CFRPageActions.test.js b/browser/components/asrouter/tests/unit/CFRPageActions.test.js new file mode 100644 index 0000000000..31970eb43a --- /dev/null +++ b/browser/components/asrouter/tests/unit/CFRPageActions.test.js @@ -0,0 +1,1414 @@ +/* eslint max-nested-callbacks: ["error", 100] */ + +import { CFRPageActions, PageAction } from "modules/CFRPageActions.sys.mjs"; +import { FAKE_RECOMMENDATION } from "./constants"; +import { GlobalOverrider } from "test/unit/utils"; +import { CFRMessageProvider } from "modules/CFRMessageProvider.sys.mjs"; + +describe("CFRPageActions", () => { + let sandbox; + let clock; + let fakeRecommendation; + let fakeHost; + let fakeBrowser; + let dispatchStub; + let globals; + let containerElem; + let elements; + let announceStub; + let fakeRemoteL10n; + let isElmVisibleStub; + let getWidgetStub; + + const elementIDs = [ + "urlbar", + "urlbar-input", + "contextual-feature-recommendation", + "cfr-button", + "cfr-label", + "contextual-feature-recommendation-notification", + "cfr-notification-header-label", + "cfr-notification-header-link", + "cfr-notification-header-image", + "cfr-notification-author", + "cfr-notification-footer", + "cfr-notification-footer-text", + "cfr-notification-footer-filled-stars", + "cfr-notification-footer-empty-stars", + "cfr-notification-footer-users", + "cfr-notification-footer-spacer", + "cfr-notification-footer-learn-more-link", + ]; + const elementClassNames = ["popup-notification-body-container"]; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + clock = sandbox.useFakeTimers(); + isElmVisibleStub = sandbox.stub().returns(true); + getWidgetStub = sandbox.stub(); + + announceStub = sandbox.stub(); + const A11yUtils = { announce: announceStub }; + fakeRecommendation = { ...FAKE_RECOMMENDATION }; + fakeHost = "mozilla.org"; + fakeBrowser = { + documentURI: { + scheme: "https", + host: fakeHost, + }, + ownerGlobal: window, + }; + dispatchStub = sandbox.stub(); + + fakeRemoteL10n = { + l10n: {}, + reloadL10n: sandbox.stub(), + createElement: sandbox.stub().returns(document.createElement("div")), + }; + + const gURLBar = document.createElement("div"); + gURLBar.textbox = document.createElement("div"); + + globals = new GlobalOverrider(); + globals.set({ + RemoteL10n: fakeRemoteL10n, + promiseDocumentFlushed: sandbox + .stub() + .callsFake(fn => Promise.resolve(fn())), + PopupNotifications: { + show: sandbox.stub(), + remove: sandbox.stub(), + }, + PrivateBrowsingUtils: { isWindowPrivate: sandbox.stub().returns(false) }, + gBrowser: { selectedBrowser: fakeBrowser }, + A11yUtils, + gURLBar, + isElementVisible: isElmVisibleStub, + CustomizableUI: { getWidget: getWidgetStub }, + }); + document.createXULElement = document.createElement; + + elements = {}; + const [body] = document.getElementsByTagName("body"); + containerElem = document.createElement("div"); + body.appendChild(containerElem); + for (const id of elementIDs) { + const elem = document.createElement("div"); + elem.setAttribute("id", id); + containerElem.appendChild(elem); + elements[id] = elem; + } + for (const className of elementClassNames) { + const elem = document.createElement("div"); + elem.setAttribute("class", className); + containerElem.appendChild(elem); + elements[className] = elem; + } + }); + + afterEach(() => { + CFRPageActions.clearRecommendations(); + containerElem.remove(); + sandbox.restore(); + globals.restore(); + }); + + describe("PageAction", () => { + let pageAction; + + beforeEach(() => { + pageAction = new PageAction(window, dispatchStub); + }); + + describe("#addImpression", () => { + it("should call _sendTelemetry with the impression payload", () => { + const recommendation = { + id: "foo", + content: { bucket_id: "bar" }, + }; + sandbox.spy(pageAction, "_sendTelemetry"); + + pageAction.addImpression(recommendation); + + assert.calledWith(pageAction._sendTelemetry, { + message_id: "foo", + bucket_id: "bar", + event: "IMPRESSION", + }); + }); + }); + + describe("#showAddressBarNotifier", () => { + it("should un-hideAddressBarNotifier the element and set the right label value", async () => { + await pageAction.showAddressBarNotifier(fakeRecommendation); + assert.isFalse(pageAction.container.hidden); + assert.equal( + pageAction.label.value, + fakeRecommendation.content.notification_text + ); + }); + it("should wait for the document layout to flush", async () => { + sandbox.spy(pageAction.label, "getClientRects"); + await pageAction.showAddressBarNotifier(fakeRecommendation); + assert.calledOnce(global.promiseDocumentFlushed); + assert.callOrder( + global.promiseDocumentFlushed, + pageAction.label.getClientRects + ); + }); + it("should set the CSS variable --cfr-label-width correctly", async () => { + await pageAction.showAddressBarNotifier(fakeRecommendation); + const expectedWidth = pageAction.label.getClientRects()[0].width; + assert.equal( + pageAction.urlbarinput.style.getPropertyValue("--cfr-label-width"), + `${expectedWidth}px` + ); + }); + it("should cause an expansion, and dispatch an impression if `expand` is true", async () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + sandbox.spy(pageAction, "_expand"); + sandbox.spy(pageAction, "_dispatchImpression"); + + await pageAction.showAddressBarNotifier(fakeRecommendation); + assert.notCalled(pageAction._dispatchImpression); + clock.tick(1001); + assert.notEqual( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + + await pageAction.showAddressBarNotifier(fakeRecommendation, true); + assert.calledOnce(pageAction._clearScheduledStateChanges); + clock.tick(1001); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + assert.calledOnce(pageAction._dispatchImpression); + assert.calledWith(pageAction._dispatchImpression, fakeRecommendation); + }); + it("should send telemetry if `expand` is true and the id and bucket_id are provided", async () => { + await pageAction.showAddressBarNotifier(fakeRecommendation, true); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "IMPRESSION", + }, + }); + }); + }); + + describe("#hideAddressBarNotifier", () => { + it("should hideAddressBarNotifier the container, cancel any state changes, and remove the state attribute", () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + pageAction.hideAddressBarNotifier(); + assert.isTrue(pageAction.container.hidden); + assert.calledOnce(pageAction._clearScheduledStateChanges); + assert.isNull( + pageAction.urlbar.getAttribute("cfr-recommendation-state") + ); + }); + it("should remove the `currentNotification`", () => { + const notification = {}; + pageAction.currentNotification = notification; + pageAction.hideAddressBarNotifier(); + assert.calledWith(global.PopupNotifications.remove, notification); + }); + }); + + describe("#_expand", () => { + beforeEach(() => { + pageAction._clearScheduledStateChanges(); + pageAction.urlbar.removeAttribute("cfr-recommendation-state"); + }); + it("without a delay, should clear other state changes and set the state to 'expanded'", () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + pageAction._expand(); + assert.calledOnce(pageAction._clearScheduledStateChanges); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + }); + it("with a delay, should set the expanded state after the correct amount of time", () => { + const delay = 1234; + pageAction._expand(delay); + // We expect that an expansion has been scheduled + assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1); + clock.tick(delay + 1); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + }); + }); + + describe("#_collapse", () => { + beforeEach(() => { + pageAction._clearScheduledStateChanges(); + pageAction.urlbar.removeAttribute("cfr-recommendation-state"); + }); + it("without a delay, should clear other state changes and set the state to collapsed only if it's already expanded", () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + pageAction._collapse(); + assert.calledOnce(pageAction._clearScheduledStateChanges); + assert.isNull( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state") + ); + pageAction.urlbarinput.setAttribute( + "cfr-recommendation-state", + "expanded" + ); + pageAction._collapse(); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "collapsed" + ); + }); + it("with a delay, should set the collapsed state after the correct amount of time", () => { + const delay = 1234; + pageAction._collapse(delay); + clock.tick(delay + 1); + // The state was _not_ "expanded" and so should not have been set to "collapsed" + assert.isNull( + pageAction.urlbar.getAttribute("cfr-recommendation-state") + ); + + pageAction._expand(); + pageAction._collapse(delay); + // We expect that a collapse has been scheduled + assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1); + clock.tick(delay + 1); + // This time it was "expanded" so should now (after the delay) be "collapsed" + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "collapsed" + ); + }); + }); + + describe("#_clearScheduledStateChanges", () => { + it("should call .clearTimeout on all stored timeoutIDs", () => { + pageAction.stateTransitionTimeoutIDs = [42, 73, 1997]; + sandbox.spy(pageAction.window, "clearTimeout"); + pageAction._clearScheduledStateChanges(); + assert.calledThrice(pageAction.window.clearTimeout); + assert.calledWith(pageAction.window.clearTimeout, 42); + assert.calledWith(pageAction.window.clearTimeout, 73); + assert.calledWith(pageAction.window.clearTimeout, 1997); + }); + }); + + describe("#_popupStateChange", () => { + it("should collapse the notification and send dismiss telemetry on 'dismissed'", () => { + pageAction._expand(); + + sandbox.spy(pageAction, "_sendTelemetry"); + + pageAction._popupStateChange("dismissed"); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "collapsed" + ); + + assert.equal( + pageAction._sendTelemetry.lastCall.args[0].event, + "DISMISS" + ); + }); + it("should remove the notification on 'removed'", () => { + pageAction._expand(); + const fakeNotification = {}; + + pageAction.currentNotification = fakeNotification; + pageAction._popupStateChange("removed"); + assert.calledOnce(global.PopupNotifications.remove); + assert.calledWith(global.PopupNotifications.remove, fakeNotification); + }); + it("should do nothing for other states", () => { + pageAction._popupStateChange("opened"); + assert.notCalled(global.PopupNotifications.remove); + }); + }); + + describe("#dispatchUserAction", () => { + it("should call ._dispatchCFRAction with the right action", () => { + const fakeAction = {}; + pageAction.dispatchUserAction(fakeAction); + assert.calledOnce(dispatchStub); + assert.calledWith( + dispatchStub, + { type: "USER_ACTION", data: fakeAction }, + fakeBrowser + ); + }); + }); + + describe("#_dispatchImpression", () => { + it("should call ._dispatchCFRAction with the right action", () => { + pageAction._dispatchImpression("fake impression"); + assert.calledWith(dispatchStub, { + type: "IMPRESSION", + data: "fake impression", + }); + }); + }); + + describe("#_sendTelemetry", () => { + it("should call ._dispatchCFRAction with the right action", () => { + const fakePing = { message_id: 42 }; + pageAction._sendTelemetry(fakePing); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: 42, + }, + }); + }); + }); + + describe("#_blockMessage", () => { + it("should call ._dispatchCFRAction with the right action", () => { + pageAction._blockMessage("fake id"); + assert.calledOnce(dispatchStub); + assert.calledWith(dispatchStub, { + type: "BLOCK_MESSAGE_BY_ID", + data: { id: "fake id" }, + }); + }); + }); + + describe("#getStrings", () => { + let formatMessagesStub; + const localeStrings = [ + { + value: "ä½ å¥½ä¸–ç•Œ", + attributes: [ + { name: "first_attr", value: 42 }, + { name: "second_attr", value: "some string" }, + { name: "third_attr", value: [1, 2, 3] }, + ], + }, + ]; + + beforeEach(() => { + formatMessagesStub = sandbox + .stub() + .withArgs({ id: "hello_world" }) + .resolves(localeStrings); + global.RemoteL10n.l10n.formatMessages = formatMessagesStub; + }); + + it("should return the argument if a string_id is not defined", async () => { + assert.deepEqual(await pageAction.getStrings({}), {}); + assert.equal(await pageAction.getStrings("some string"), "some string"); + }); + it("should get the right locale string", async () => { + assert.equal( + await pageAction.getStrings({ string_id: "hello_world" }), + localeStrings[0].value + ); + }); + it("should return the right sub-attribute if specified", async () => { + assert.equal( + await pageAction.getStrings( + { string_id: "hello_world" }, + "second_attr" + ), + "some string" + ); + }); + it("should attach attributes to string overrides", async () => { + const fromJson = { value: "Add Now", attributes: { accesskey: "A" } }; + + const result = await pageAction.getStrings(fromJson); + + assert.equal(result, fromJson.value); + assert.propertyVal(result.attributes, "accesskey", "A"); + }); + it("should return subAttributes when doing string overrides", async () => { + const fromJson = { value: "Add Now", attributes: { accesskey: "A" } }; + + const result = await pageAction.getStrings(fromJson, "accesskey"); + + assert.equal(result, "A"); + }); + it("should resolve ftl strings and attach subAttributes", async () => { + const fromFtl = { string_id: "cfr-doorhanger-extension-ok-button" }; + formatMessagesStub.resolves([ + { value: "Add Now", attributes: [{ name: "accesskey", value: "A" }] }, + ]); + + const result = await pageAction.getStrings(fromFtl); + + assert.equal(result, "Add Now"); + assert.propertyVal(result.attributes, "accesskey", "A"); + }); + it("should return subAttributes from ftl ids", async () => { + const fromFtl = { string_id: "cfr-doorhanger-extension-ok-button" }; + formatMessagesStub.resolves([ + { value: "Add Now", attributes: [{ name: "accesskey", value: "A" }] }, + ]); + + const result = await pageAction.getStrings(fromFtl, "accesskey"); + + assert.equal(result, "A"); + }); + it("should report an error when no attributes are present but subAttribute is requested", async () => { + const fromJson = { value: "Foo" }; + const stub = sandbox.stub(global.console, "error"); + + await pageAction.getStrings(fromJson, "accesskey"); + + assert.calledOnce(stub); + stub.restore(); + }); + }); + + describe("#_cfrUrlbarButtonClick", () => { + let translateElementsStub; + let setAttributesStub; + let getStringsStub; + beforeEach(async () => { + CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + getStringsStub = sandbox.stub(pageAction, "getStrings").resolves(""); + getStringsStub + .callsFake(async a => a) // eslint-disable-line max-nested-callbacks + .withArgs({ string_id: "primary_button_id" }) + .resolves({ value: "Primary Button", attributes: { accesskey: "p" } }) + .withArgs({ string_id: "secondary_button_id" }) + .resolves({ + value: "Secondary Button", + attributes: { accesskey: "s" }, + }) + .withArgs({ string_id: "secondary_button_id_2" }) + .resolves({ + value: "Secondary Button 2", + attributes: { accesskey: "a" }, + }) + .withArgs({ string_id: "secondary_button_id_3" }) + .resolves({ + value: "Secondary Button 3", + attributes: { accesskey: "g" }, + }) + .withArgs( + sinon.match({ + string_id: "cfr-doorhanger-extension-learn-more-link", + }) + ) + .resolves("Learn more") + .withArgs( + sinon.match({ string_id: "cfr-doorhanger-extension-total-users" }) + ) + .callsFake(async ({ args }) => `${args.total} users`); // eslint-disable-line max-nested-callbacks + + translateElementsStub = sandbox.stub().resolves(); + setAttributesStub = sandbox.stub(); + global.RemoteL10n.l10n.setAttributes = setAttributesStub; + global.RemoteL10n.l10n.translateElements = translateElementsStub; + }); + + it("should call `.hideAddressBarNotifier` and do nothing if there is no recommendation for the selected browser", async () => { + sandbox.spy(pageAction, "hideAddressBarNotifier"); + CFRPageActions.RecommendationMap.delete(fakeBrowser); + await pageAction._cfrUrlbarButtonClick({}); + assert.calledOnce(pageAction.hideAddressBarNotifier); + assert.notCalled(global.PopupNotifications.show); + }); + it("should cancel any planned state changes", async () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + assert.notCalled(pageAction._clearScheduledStateChanges); + await pageAction._cfrUrlbarButtonClick({}); + assert.calledOnce(pageAction._clearScheduledStateChanges); + }); + it("should set the right text values", async () => { + await pageAction._cfrUrlbarButtonClick({}); + const headerLabel = elements["cfr-notification-header-label"]; + const headerLink = elements["cfr-notification-header-link"]; + const headerImage = elements["cfr-notification-header-image"]; + const footerLink = elements["cfr-notification-footer-learn-more-link"]; + assert.equal( + headerLabel.value, + fakeRecommendation.content.heading_text + ); + assert.isTrue( + headerLink + .getAttribute("href") + .endsWith(fakeRecommendation.content.info_icon.sumo_path) + ); + assert.equal( + headerImage.getAttribute("tooltiptext"), + fakeRecommendation.content.info_icon.label + ); + const htmlFooterEl = fakeRemoteL10n.createElement.args.find( + /* eslint-disable-next-line max-nested-callbacks */ + ([doc, el, args]) => + args && args.content === fakeRecommendation.content.text + ); + assert.ok(htmlFooterEl); + assert.equal(footerLink.value, "Learn more"); + assert.equal( + footerLink.getAttribute("href"), + fakeRecommendation.content.addon.amo_url + ); + }); + it("should add the rating correctly", async () => { + await pageAction._cfrUrlbarButtonClick(); + const footerFilledStars = + elements["cfr-notification-footer-filled-stars"]; + const footerEmptyStars = + elements["cfr-notification-footer-empty-stars"]; + // .toFixed to sort out some floating precision errors + assert.equal( + footerFilledStars.style.width, + `${(4.2 * 16).toFixed(1)}px` + ); + assert.equal( + footerEmptyStars.style.width, + `${(0.8 * 16).toFixed(1)}px` + ); + }); + it("should add the number of users correctly", async () => { + await pageAction._cfrUrlbarButtonClick(); + const footerUsers = elements["cfr-notification-footer-users"]; + assert.isNull(footerUsers.getAttribute("hidden")); + assert.equal( + footerUsers.getAttribute("value"), + `${fakeRecommendation.content.addon.users}` + ); + }); + it("should send the right telemetry", async () => { + await pageAction._cfrUrlbarButtonClick(); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "CLICK_DOORHANGER", + }, + }); + }); + it("should set the main action correctly", async () => { + sinon + .stub(CFRPageActions, "_fetchLatestAddonVersion") + .resolves("latest-addon.xpi"); + await pageAction._cfrUrlbarButtonClick(); + const mainAction = global.PopupNotifications.show.firstCall.args[4]; // eslint-disable-line prefer-destructuring + assert.deepEqual(mainAction.label, { + value: "Primary Button", + attributes: { accesskey: "p" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + await mainAction.callback(); + assert.calledOnce(pageAction.hideAddressBarNotifier); + // Should block the message + assert.calledWith(dispatchStub, { + type: "BLOCK_MESSAGE_BY_ID", + data: { id: fakeRecommendation.id }, + }); + // Should trigger the action + assert.calledWith( + dispatchStub, + { + type: "USER_ACTION", + data: { id: "primary_action", data: { url: "latest-addon.xpi" } }, + }, + fakeBrowser + ); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "INSTALL", + }, + }); + // Should remove the recommendation + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should set the secondary action correctly", async () => { + await pageAction._cfrUrlbarButtonClick(); + // eslint-disable-next-line prefer-destructuring + const [secondaryAction] = + global.PopupNotifications.show.firstCall.args[5]; + + assert.deepEqual(secondaryAction.label, { + value: "Secondary Button", + attributes: { accesskey: "s" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + CFRPageActions.RecommendationMap.set(fakeBrowser, {}); + secondaryAction.callback(); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "DISMISS", + }, + }); + // Don't remove the recommendation on `DISMISS` action + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + assert.notCalled(pageAction.hideAddressBarNotifier); + }); + it("should send right telemetry for BLOCK secondary action", async () => { + await pageAction._cfrUrlbarButtonClick(); + // eslint-disable-next-line prefer-destructuring + const blockAction = global.PopupNotifications.show.firstCall.args[5][1]; + + assert.deepEqual(blockAction.label, { + value: "Secondary Button 2", + attributes: { accesskey: "a" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + sandbox.spy(pageAction, "_blockMessage"); + CFRPageActions.RecommendationMap.set(fakeBrowser, {}); + blockAction.callback(); + assert.calledOnce(pageAction.hideAddressBarNotifier); + assert.calledOnce(pageAction._blockMessage); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "BLOCK", + }, + }); + // Should remove the recommendation + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should send right telemetry for MANAGE secondary action", async () => { + await pageAction._cfrUrlbarButtonClick(); + // eslint-disable-next-line prefer-destructuring + const manageAction = + global.PopupNotifications.show.firstCall.args[5][2]; + + assert.deepEqual(manageAction.label, { + value: "Secondary Button 3", + attributes: { accesskey: "g" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + CFRPageActions.RecommendationMap.set(fakeBrowser, {}); + manageAction.callback(); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "MANAGE", + }, + }); + // Don't remove the recommendation on `MANAGE` action + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + assert.notCalled(pageAction.hideAddressBarNotifier); + }); + it("should call PopupNotifications.show with the right arguments", async () => { + await pageAction._cfrUrlbarButtonClick(); + assert.calledWith( + global.PopupNotifications.show, + fakeBrowser, + "contextual-feature-recommendation", + fakeRecommendation.content.addon.title, + "cfr", + sinon.match.any, // Corresponds to the main action, tested above + sinon.match.any, // Corresponds to the secondary action, tested above + { + popupIconURL: fakeRecommendation.content.addon.icon, + hideClose: true, + eventCallback: pageAction._popupStateChange, + persistent: false, + persistWhileVisible: false, + popupIconClass: fakeRecommendation.content.icon_class, + recordTelemetryInPrivateBrowsing: + fakeRecommendation.content.show_in_private_browsing, + name: { + string_id: "cfr-doorhanger-extension-author", + args: { name: fakeRecommendation.content.addon.author }, + }, + } + ); + }); + }); + describe("#_cfrUrlbarButtonClick/cfr_urlbar_chiclet", () => { + let heartbeatRecommendation; + beforeEach(async () => { + heartbeatRecommendation = (await CFRMessageProvider.getMessages()).find( + m => m.template === "cfr_urlbar_chiclet" + ); + CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + heartbeatRecommendation, + dispatchStub + ); + }); + it("should dispatch a click event", async () => { + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: heartbeatRecommendation.id, + bucket_id: heartbeatRecommendation.content.bucket_id, + event: "CLICK_DOORHANGER", + }, + }); + }); + it("should dispatch a USER_ACTION for chiclet_open_url layout", async () => { + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledWith(dispatchStub, { + type: "USER_ACTION", + data: { + data: { + args: heartbeatRecommendation.content.action.url, + where: heartbeatRecommendation.content.action.where, + }, + type: "OPEN_URL", + }, + }); + }); + it("should block the message after the click", async () => { + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledWith(dispatchStub, { + type: "BLOCK_MESSAGE_BY_ID", + data: { id: heartbeatRecommendation.id }, + }); + }); + it("should remove the button and browser entry", async () => { + sandbox.spy(pageAction, "hideAddressBarNotifier"); + + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledOnce(pageAction.hideAddressBarNotifier); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + }); + + describe("#showMilestonePopup", () => { + let milestoneRecommendation; + let fakeTrackingDBService; + beforeEach(async () => { + fakeTrackingDBService = { + sumAllEvents: sandbox.stub(), + }; + globals.set({ TrackingDBService: fakeTrackingDBService }); + CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction); + sandbox + .stub(pageAction, "getStrings") + .callsFake(async a => a) // eslint-disable-line max-nested-callbacks + .resolves({ value: "element", attributes: { accesskey: "e" } }); + + milestoneRecommendation = (await CFRMessageProvider.getMessages()).find( + m => m.template === "milestone_message" + ); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + it("Set current date in header when earliest date undefined", async () => { + fakeTrackingDBService.getEarliestRecordedDate = sandbox.stub(); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + milestoneRecommendation, + dispatchStub + ); + const [, , headerElementArgs] = fakeRemoteL10n.createElement.args.find( + /* eslint-disable-next-line max-nested-callbacks */ + ([doc, el, args]) => args && args.content && args.attributes + ); + assert.equal( + headerElementArgs.content.string_id, + milestoneRecommendation.content.heading_text.string_id + ); + assert.equal(headerElementArgs.attributes.date, new Date().getTime()); + assert.calledOnce(global.PopupNotifications.show); + }); + + it("Set date in header to earliest date timestamp by default", async () => { + let earliestDateTimeStamp = 1705601996435; + fakeTrackingDBService.getEarliestRecordedDate = sandbox + .stub() + .returns(earliestDateTimeStamp); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + milestoneRecommendation, + dispatchStub + ); + const [, , headerElementArgs] = fakeRemoteL10n.createElement.args.find( + /* eslint-disable-next-line max-nested-callbacks */ + ([doc, el, args]) => args && args.content && args.attributes + ); + assert.equal( + headerElementArgs.content.string_id, + milestoneRecommendation.content.heading_text.string_id + ); + assert.equal(headerElementArgs.attributes.date, earliestDateTimeStamp); + assert.calledOnce(global.PopupNotifications.show); + }); + }); + }); + + describe("CFRPageActions", () => { + beforeEach(() => { + // Spy on the prototype methods to inspect calls for any PageAction instance + sandbox.spy(PageAction.prototype, "showAddressBarNotifier"); + sandbox.spy(PageAction.prototype, "hideAddressBarNotifier"); + }); + + describe("updatePageActions", () => { + let savedRec; + + beforeEach(() => { + const win = fakeBrowser.ownerGlobal; + CFRPageActions.PageActionMap.set( + win, + new PageAction(win, dispatchStub) + ); + const { id, content } = fakeRecommendation; + savedRec = { + id, + host: fakeHost, + content, + }; + CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec); + }); + + it("should do nothing if a pageAction doesn't exist for the window", () => { + const win = fakeBrowser.ownerGlobal; + CFRPageActions.PageActionMap.delete(win); + CFRPageActions.updatePageActions(fakeBrowser); + assert.notCalled(PageAction.prototype.showAddressBarNotifier); + assert.notCalled(PageAction.prototype.hideAddressBarNotifier); + }); + it("should do nothing if the browser is not the `selectedBrowser`", () => { + const someOtherFakeBrowser = {}; + CFRPageActions.updatePageActions(someOtherFakeBrowser); + assert.notCalled(PageAction.prototype.showAddressBarNotifier); + assert.notCalled(PageAction.prototype.hideAddressBarNotifier); + }); + it("should hideAddressBarNotifier the pageAction if a recommendation doesn't exist for the given browser", () => { + CFRPageActions.RecommendationMap.delete(fakeBrowser); + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + }); + it("should show the pageAction if a recommendation exists and the host matches", () => { + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + assert.calledWith( + PageAction.prototype.showAddressBarNotifier, + savedRec + ); + }); + it("should show the pageAction if a recommendation exists and it doesn't have a host defined", () => { + const recNoHost = { ...savedRec, host: undefined }; + CFRPageActions.RecommendationMap.set(fakeBrowser, recNoHost); + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + assert.calledWith( + PageAction.prototype.showAddressBarNotifier, + recNoHost + ); + }); + it("should hideAddressBarNotifier the pageAction and delete the recommendation if the recommendation exists but the host doesn't match", () => { + const someOtherFakeHost = "subdomain.mozilla.com"; + fakeBrowser.documentURI.host = someOtherFakeHost; + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should not call `delete` if retain is true", () => { + savedRec.retain = true; + fakeBrowser.documentURI.host = "subdomain.mozilla.com"; + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + + CFRPageActions.updatePageActions(fakeBrowser); + assert.propertyVal(savedRec, "retain", false); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should call `delete` if retain is false", () => { + savedRec.retain = false; + fakeBrowser.documentURI.host = "subdomain.mozilla.com"; + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + + CFRPageActions.updatePageActions(fakeBrowser); + assert.propertyVal(savedRec, "retain", false); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + }); + + describe("forceRecommendation", () => { + it("should succeed and add an element to the RecommendationMap", async () => { + assert.isTrue( + await CFRPageActions.forceRecommendation( + fakeBrowser, + fakeRecommendation, + dispatchStub + ) + ); + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + content: fakeRecommendation.content, + }); + }); + it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => { + const win = fakeBrowser.ownerGlobal; + assert.isFalse(CFRPageActions.PageActionMap.has(win)); + await CFRPageActions.forceRecommendation( + fakeBrowser, + fakeRecommendation, + dispatchStub + ); + const pageAction = CFRPageActions.PageActionMap.get(win); + assert.equal(win, pageAction.window); + assert.equal(dispatchStub, pageAction._dispatchCFRAction); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + }); + }); + + describe("showPopup", () => { + let savedRec; + let pageAction; + let fakeAnchorId = "fake_anchor_id"; + let fakeAltAnchorId = "fake_alt_anchor_id"; + let TEST_MESSAGE; + let getElmStub; + let getStyleStub; + let isCustomizingStub; + beforeEach(() => { + TEST_MESSAGE = { + id: "fake_id", + template: "cfr_doorhanger", + content: { + skip_address_bar_notifier: true, + heading_text: "Fake Heading Text", + anchor_id: fakeAnchorId, + }, + }; + getElmStub = sandbox + .stub(window.document, "getElementById") + .callsFake(id => ({ id })); + getStyleStub = sandbox + .stub(window, "getComputedStyle") + .returns({ display: "block", visibility: "visible" }); + + isCustomizingStub = sandbox.stub().returns(false); + globals.set({ + CustomizationHandler: { isCustomizing: isCustomizingStub }, + }); + + savedRec = { + id: TEST_MESSAGE.id, + host: fakeHost, + content: TEST_MESSAGE.content, + }; + CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec); + pageAction = new PageAction(window, dispatchStub); + sandbox.stub(pageAction, "_renderPopup"); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + it("should use anchor_id if element exists and is not a customizable widget", async () => { + await pageAction.showPopup(); + assert.equal(fakeBrowser.cfrpopupnotificationanchor.id, fakeAnchorId); + }); + + it("should use anchor_id if element exists and is in the toolbar", async () => { + getWidgetStub.withArgs(fakeAnchorId).returns({ areaType: "toolbar" }); + await pageAction.showPopup(); + assert.equal(fakeBrowser.cfrpopupnotificationanchor.id, fakeAnchorId); + }); + + it("should use the cfr button if element exists but is in the widget overflow panel", async () => { + getWidgetStub + .withArgs(fakeAnchorId) + .returns({ areaType: "menu-panel" }); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + pageAction.button.id + ); + }); + + it("should use the cfr button if element exists but is in the customization palette", async () => { + getWidgetStub.withArgs(fakeAnchorId).returns({ areaType: null }); + isCustomizingStub.returns(true); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + pageAction.button.id + ); + }); + + it("should use alt_anchor_id if one has been provided and the anchor_id element cannot be found", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + getElmStub.withArgs(fakeAnchorId).returns(null); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + fakeAltAnchorId + ); + }); + + it("should use alt_anchor_id if one has been provided and the anchor_id element is hidden by CSS", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + getStyleStub + .withArgs(sandbox.match({ id: fakeAnchorId })) + .returns({ display: "none", visibility: "visible" }); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + fakeAltAnchorId + ); + }); + + it("should use alt_anchor_id if one has been provided and the anchor_id element has no height/width", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + isElmVisibleStub + .withArgs(sandbox.match({ id: fakeAnchorId })) + .returns(false); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + fakeAltAnchorId + ); + }); + + it("should use the button if the anchor_id and alt_anchor_id are both not visible", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + getStyleStub + .withArgs(sandbox.match({ id: fakeAnchorId })) + .returns({ display: "none", visibility: "visible" }); + getWidgetStub.withArgs(fakeAltAnchorId).returns({ areaType: null }); + isCustomizingStub.returns(true); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + pageAction.button.id + ); + }); + + it("should use the default container if the anchor_id, alt_anchor_id, and cfr button are not visible", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + getStyleStub + .withArgs(sandbox.match({ id: fakeAnchorId })) + .returns({ display: "none", visibility: "visible" }); + getStyleStub + .withArgs(sandbox.match({ id: "cfr-button" })) + .returns({ display: "none", visibility: "visible" }); + getWidgetStub.withArgs(fakeAltAnchorId).returns({ areaType: null }); + isCustomizingStub.returns(true); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + pageAction.container.id + ); + }); + }); + + describe("addRecommendation", () => { + it("should fail and not add a recommendation if the browser is part of a private window", async () => { + global.PrivateBrowsingUtils.isWindowPrivate.returns(true); + assert.isFalse( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should successfully add a private browsing recommendation and send correct telemetry", async () => { + global.PrivateBrowsingUtils.isWindowPrivate.returns(true); + fakeRecommendation.content.show_in_private_browsing = true; + assert.isTrue( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + + const pageAction = CFRPageActions.PageActionMap.get( + fakeBrowser.ownerGlobal + ); + await pageAction.showAddressBarNotifier(fakeRecommendation, true); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + is_private: true, + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "IMPRESSION", + }, + }); + }); + it("should fail and not add a recommendation if the browser is not the selected browser", async () => { + global.gBrowser.selectedBrowser = {}; // Some other browser + assert.isFalse( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + }); + it("should fail and not add a recommendation if the browser does not exist", async () => { + assert.isFalse( + await CFRPageActions.addRecommendation( + undefined, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should fail and not add a recommendation if the host doesn't match", async () => { + const someOtherFakeHost = "subdomain.mozilla.com"; + assert.isFalse( + await CFRPageActions.addRecommendation( + fakeBrowser, + someOtherFakeHost, + fakeRecommendation, + dispatchStub + ) + ); + }); + it("should otherwise succeed and add an element to the RecommendationMap", async () => { + assert.isTrue( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + host: fakeHost, + content: fakeRecommendation.content, + }); + }); + it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => { + const win = fakeBrowser.ownerGlobal; + assert.isFalse(CFRPageActions.PageActionMap.has(win)); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + const pageAction = CFRPageActions.PageActionMap.get(win); + assert.equal(win, pageAction.window); + assert.equal(dispatchStub, pageAction._dispatchCFRAction); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + }); + it("should add the right url if we fetched and addon install URL", async () => { + fakeRecommendation.template = "cfr_doorhanger"; + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + const recommendation = + CFRPageActions.RecommendationMap.get(fakeBrowser); + + // sanity check - just go through some of the rest of the attributes to make sure they were untouched + assert.equal(recommendation.id, fakeRecommendation.id); + assert.equal( + recommendation.content.heading_text, + fakeRecommendation.content.heading_text + ); + assert.equal( + recommendation.content.addon, + fakeRecommendation.content.addon + ); + assert.equal( + recommendation.content.text, + fakeRecommendation.content.text + ); + assert.equal( + recommendation.content.buttons.secondary, + fakeRecommendation.content.buttons.secondary + ); + assert.equal( + recommendation.content.buttons.primary.action.id, + fakeRecommendation.content.buttons.primary.action.id + ); + + delete fakeRecommendation.template; + }); + it("should prevent a second message if one is currently displayed", async () => { + const secondMessage = { ...fakeRecommendation, id: "second_message" }; + let messageAdded = await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + + assert.isTrue(messageAdded); + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + host: fakeHost, + content: fakeRecommendation.content, + }); + + messageAdded = await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + secondMessage, + dispatchStub + ); + // Adding failed + assert.isFalse(messageAdded); + // First message is still there + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + host: fakeHost, + content: fakeRecommendation.content, + }); + }); + it("should send impressions just for the first message", async () => { + const secondMessage = { ...fakeRecommendation, id: "second_message" }; + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + secondMessage, + dispatchStub + ); + + // Doorhanger telemetry + Impression for just 1 message + assert.calledTwice(dispatchStub); + const [firstArgs] = dispatchStub.firstCall.args; + const [secondArgs] = dispatchStub.secondCall.args; + assert.equal(firstArgs.data.id, secondArgs.data.message_id); + }); + }); + + describe("clearRecommendations", () => { + const createFakePageAction = () => ({ + hideAddressBarNotifier: sandbox.stub(), + }); + const windows = [{}, {}, { closed: true }]; + const browsers = [{}, {}, {}, {}]; + + beforeEach(() => { + CFRPageActions.PageActionMap.set(windows[0], createFakePageAction()); + CFRPageActions.PageActionMap.set(windows[2], createFakePageAction()); + for (const browser of browsers) { + CFRPageActions.RecommendationMap.set(browser, {}); + } + globals.set({ Services: { wm: { getEnumerator: () => windows } } }); + }); + + it("should hideAddressBarNotifier the PageActions of any existing, non-closed windows", () => { + const pageActions = windows.map(win => + CFRPageActions.PageActionMap.get(win) + ); + CFRPageActions.clearRecommendations(); + + // Only the first window had a PageAction and wasn't closed + assert.calledOnce(pageActions[0].hideAddressBarNotifier); + assert.isUndefined(pageActions[1]); + assert.notCalled(pageActions[2].hideAddressBarNotifier); + }); + it("should clear the PageActionMap and the RecommendationMap", () => { + CFRPageActions.clearRecommendations(); + + // Both are WeakMaps and so are not iterable, cannot be cleared, and + // cannot have their length queried directly, so we have to check + // whether previous elements still exist + assert.lengthOf(windows, 3); + for (const win of windows) { + assert.isFalse(CFRPageActions.PageActionMap.has(win)); + } + assert.lengthOf(browsers, 4); + for (const browser of browsers) { + assert.isFalse(CFRPageActions.RecommendationMap.has(browser)); + } + }); + }); + + describe("reloadL10n", () => { + const createFakePageAction = () => ({ + hideAddressBarNotifier() {}, + reloadL10n: sandbox.stub(), + }); + const windows = [{}, {}, { closed: true }]; + + beforeEach(() => { + CFRPageActions.PageActionMap.set(windows[0], createFakePageAction()); + CFRPageActions.PageActionMap.set(windows[2], createFakePageAction()); + globals.set({ Services: { wm: { getEnumerator: () => windows } } }); + }); + + it("should call reloadL10n for all the PageActions of any existing, non-closed windows", () => { + const pageActions = windows.map(win => + CFRPageActions.PageActionMap.get(win) + ); + CFRPageActions.reloadL10n(); + + // Only the first window had a PageAction and wasn't closed + assert.calledOnce(pageActions[0].reloadL10n); + assert.isUndefined(pageActions[1]); + assert.notCalled(pageActions[2].reloadL10n); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/MessageLoaderUtils.test.js b/browser/components/asrouter/tests/unit/MessageLoaderUtils.test.js new file mode 100644 index 0000000000..463e388651 --- /dev/null +++ b/browser/components/asrouter/tests/unit/MessageLoaderUtils.test.js @@ -0,0 +1,459 @@ +import { MessageLoaderUtils } from "modules/ASRouter.sys.mjs"; +const { STARTPAGE_VERSION } = MessageLoaderUtils; + +const FAKE_OPTIONS = { + storage: { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve(); + }, + }, + dispatchToAS: () => {}, +}; +const FAKE_RESPONSE_HEADERS = { get() {} }; + +describe("MessageLoaderUtils", () => { + let fetchStub; + let clock; + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + clock = sinon.useFakeTimers(); + fetchStub = sinon.stub(global, "fetch"); + }); + afterEach(() => { + sandbox.restore(); + clock.restore(); + fetchStub.restore(); + }); + + describe("#loadMessagesForProvider", () => { + it("should return messages for a local provider with hardcoded messages", async () => { + const sourceMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "local", + messages: [sourceMessage], + }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.isArray(result.messages); + // Does the message have the right properties? + const [message] = result.messages; + assert.propertyVal(message, "id", "foo"); + assert.propertyVal(message, "provider", "provider123"); + }); + it("should filter out local messages listed in the `exclude` field", async () => { + const sourceMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "local", + messages: [sourceMessage], + exclude: ["foo"], + }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.lengthOf(result.messages, 0); + }); + it("should return messages for remote provider", async () => { + const sourceMessage = { id: "foo" }; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({ messages: [sourceMessage] }), + headers: FAKE_RESPONSE_HEADERS, + }); + const provider = { + id: "provider123", + type: "remote", + url: "https://foo.com", + }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.isArray(result.messages); + // Does the message have the right properties? + const [message] = result.messages; + assert.propertyVal(message, "id", "foo"); + assert.propertyVal(message, "provider", "provider123"); + assert.propertyVal(message, "provider_url", "https://foo.com"); + }); + describe("remote provider HTTP codes", () => { + const testMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "remote", + url: "https://foo.com", + updateCycleInMs: 300, + }; + const respJson = { messages: [testMessage] }; + + function assertReturnsCorrectMessages(actual) { + assert.isArray(actual.messages); + // Does the message have the right properties? + const [message] = actual.messages; + assert.propertyVal(message, "id", testMessage.id); + assert.propertyVal(message, "provider", provider.id); + assert.propertyVal(message, "provider_url", provider.url); + } + + it("should return messages for 200 response", async () => { + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(respJson), + headers: FAKE_RESPONSE_HEADERS, + }); + assertReturnsCorrectMessages( + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ) + ); + }); + + it("should return messages for a 302 response with json", async () => { + fetchStub.resolves({ + ok: true, + status: 302, + json: () => Promise.resolve(respJson), + headers: FAKE_RESPONSE_HEADERS, + }); + assertReturnsCorrectMessages( + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ) + ); + }); + + it("should return an empty array for a 204 response", async () => { + fetchStub.resolves({ + ok: true, + status: 204, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + + it("should return an empty array for a 500 response", async () => { + fetchStub.resolves({ + ok: false, + status: 500, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + + it("should return cached messages for a 304 response", async () => { + clock.tick(302); + const messages = [{ id: "message-1" }, { id: "message-2" }]; + const fakeStorage = { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve({ + [provider.id]: { + version: STARTPAGE_VERSION, + url: provider.url, + messages, + etag: "etag0987654321", + lastFetched: 1, + }, + }); + }, + }; + fetchStub.resolves({ + ok: true, + status: 304, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + { ...FAKE_OPTIONS, storage: fakeStorage } + ); + assert.equal(result.messages.length, messages.length); + messages.forEach(message => { + assert.ok(result.messages.find(m => m.id === message.id)); + }); + }); + + it("should return an empty array if json doesn't parse properly", async () => { + fetchStub.resolves({ + ok: false, + status: 200, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + + it("should report response parsing errors with MessageLoaderUtils.reportError", async () => { + const err = {}; + sandbox.spy(MessageLoaderUtils, "reportError"); + fetchStub.resolves({ + ok: true, + status: 200, + json: sandbox.stub().rejects(err), + headers: FAKE_RESPONSE_HEADERS, + }); + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.calledOnce(MessageLoaderUtils.reportError); + // Report that json parsing failed + assert.calledWith(MessageLoaderUtils.reportError, err); + }); + + it("should report missing `messages` with MessageLoaderUtils.reportError", async () => { + sandbox.spy(MessageLoaderUtils, "reportError"); + fetchStub.resolves({ + ok: true, + status: 200, + json: sandbox.stub().resolves({}), + headers: FAKE_RESPONSE_HEADERS, + }); + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.calledOnce(MessageLoaderUtils.reportError); + // Report no messages returned + assert.calledWith( + MessageLoaderUtils.reportError, + "No messages returned from https://foo.com." + ); + }); + + it("should report bad status responses with MessageLoaderUtils.reportError", async () => { + sandbox.spy(MessageLoaderUtils, "reportError"); + fetchStub.resolves({ + ok: false, + status: 500, + json: sandbox.stub().resolves({}), + headers: FAKE_RESPONSE_HEADERS, + }); + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.calledOnce(MessageLoaderUtils.reportError); + // Report no messages returned + assert.calledWith( + MessageLoaderUtils.reportError, + "Invalid response status 500 from https://foo.com." + ); + }); + + it("should return an empty array if the request rejects", async () => { + fetchStub.rejects(new Error("something went wrong")); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + }); + describe("remote provider caching", () => { + const provider = { + id: "provider123", + type: "remote", + url: "https://foo.com", + updateCycleInMs: 300, + }; + + it("should return cached results if they aren't expired", async () => { + clock.tick(1); + const messages = [{ id: "message-1" }, { id: "message-2" }]; + const fakeStorage = { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve({ + [provider.id]: { + version: STARTPAGE_VERSION, + url: provider.url, + messages, + etag: "etag0987654321", + lastFetched: Date.now(), + }, + }); + }, + }; + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + { ...FAKE_OPTIONS, storage: fakeStorage } + ); + assert.equal(result.messages.length, messages.length); + messages.forEach(message => { + assert.ok(result.messages.find(m => m.id === message.id)); + }); + }); + + it("should return fetch results if the cache messages are expired", async () => { + clock.tick(302); + const testMessage = { id: "foo" }; + const respJson = { messages: [testMessage] }; + const fakeStorage = { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve({ + [provider.id]: { + version: STARTPAGE_VERSION, + url: provider.url, + messages: [{ id: "message-1" }, { id: "message-2" }], + etag: "etag0987654321", + lastFetched: 1, + }, + }); + }, + }; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(respJson), + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + { ...FAKE_OPTIONS, storage: fakeStorage } + ); + assert.equal(result.messages.length, 1); + assert.equal(result.messages[0].id, testMessage.id); + }); + }); + it("should return an empty array for a remote provider with a blank URL without attempting a request", async () => { + const provider = { id: "provider123", type: "remote", url: "" }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.notCalled(fetchStub); + assert.deepEqual(result.messages, []); + }); + it("should return .lastUpdated with the time at which the messages were fetched", async () => { + const sourceMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "remote", + url: "foo.com", + }; + + fetchStub.resolves({ + ok: true, + status: 200, + json: () => + new Promise(resolve => { + clock.tick(42); + resolve({ messages: [sourceMessage] }); + }), + headers: FAKE_RESPONSE_HEADERS, + }); + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.propertyVal(result, "lastUpdated", 42); + }); + }); + + describe("#shouldProviderUpdate", () => { + it("should return true if the provider does not had a .lastUpdated property", () => { + assert.isTrue(MessageLoaderUtils.shouldProviderUpdate({ id: "foo" })); + }); + it("should return false if the provider does not had a .updateCycleInMs property and has a .lastUpdated", () => { + clock.tick(1); + assert.isFalse( + MessageLoaderUtils.shouldProviderUpdate({ id: "foo", lastUpdated: 0 }) + ); + }); + it("should return true if the time since .lastUpdated is greater than .updateCycleInMs", () => { + clock.tick(301); + assert.isTrue( + MessageLoaderUtils.shouldProviderUpdate({ + id: "foo", + lastUpdated: 0, + updateCycleInMs: 300, + }) + ); + }); + it("should return false if the time since .lastUpdated is less than .updateCycleInMs", () => { + clock.tick(299); + assert.isFalse( + MessageLoaderUtils.shouldProviderUpdate({ + id: "foo", + lastUpdated: 0, + updateCycleInMs: 300, + }) + ); + }); + }); + + describe("#cleanupCache", () => { + it("should remove data for providers no longer active", async () => { + const fakeStorage = { + get: sinon.stub().returns( + Promise.resolve({ + "id-1": {}, + "id-2": {}, + "id-3": {}, + }) + ), + set: sinon.stub().returns(Promise.resolve()), + }; + const fakeProviders = [ + { id: "id-1", type: "remote" }, + { id: "id-3", type: "remote" }, + ]; + + await MessageLoaderUtils.cleanupCache(fakeProviders, fakeStorage); + + assert.calledOnce(fakeStorage.set); + assert.calledWith( + fakeStorage.set, + MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, + { "id-1": {}, "id-3": {} } + ); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ModalOverlay.test.jsx b/browser/components/asrouter/tests/unit/ModalOverlay.test.jsx new file mode 100644 index 0000000000..2320e16fc3 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ModalOverlay.test.jsx @@ -0,0 +1,69 @@ +import { ModalOverlayWrapper } from "content-src/components/ModalOverlay/ModalOverlay"; +import { mount } from "enzyme"; +import React from "react"; + +describe("ModalOverlayWrapper", () => { + let fakeDoc; + let sandbox; + let header; + beforeEach(() => { + sandbox = sinon.createSandbox(); + header = document.createElement("div"); + + fakeDoc = { + addEventListener: sandbox.stub(), + removeEventListener: sandbox.stub(), + body: { classList: { add: sandbox.stub(), remove: sandbox.stub() } }, + getElementById() { + return header; + }, + }; + }); + afterEach(() => { + sandbox.restore(); + }); + it("should add eventListener and a class on mount", async () => { + mount(<ModalOverlayWrapper document={fakeDoc} />); + assert.calledOnce(fakeDoc.addEventListener); + assert.calledWith(fakeDoc.body.classList.add, "modal-open"); + }); + + it("should remove eventListener on unmount", async () => { + const wrapper = mount(<ModalOverlayWrapper document={fakeDoc} />); + wrapper.unmount(); + assert.calledOnce(fakeDoc.addEventListener); + assert.calledOnce(fakeDoc.removeEventListener); + assert.calledWith(fakeDoc.body.classList.remove, "modal-open"); + }); + + it("should call props.onClose on an Escape key", async () => { + const onClose = sandbox.stub(); + mount(<ModalOverlayWrapper document={fakeDoc} onClose={onClose} />); + + // Simulate onkeydown being called + const [, callback] = fakeDoc.addEventListener.firstCall.args; + callback({ key: "Escape" }); + + assert.calledOnce(onClose); + }); + + it("should not call props.onClose on other keys than Escape", async () => { + const onClose = sandbox.stub(); + mount(<ModalOverlayWrapper document={fakeDoc} onClose={onClose} />); + + // Simulate onkeydown being called + const [, callback] = fakeDoc.addEventListener.firstCall.args; + callback({ key: "Ctrl" }); + + assert.notCalled(onClose); + }); + + it("should not call props.onClose when clicked outside dialog", async () => { + const onClose = sandbox.stub(); + const wrapper = mount( + <ModalOverlayWrapper document={fakeDoc} onClose={onClose} /> + ); + wrapper.find("div.modalOverlayOuter.active").simulate("click"); + assert.notCalled(onClose); + }); +}); diff --git a/browser/components/asrouter/tests/unit/MomentsPageHub.test.js b/browser/components/asrouter/tests/unit/MomentsPageHub.test.js new file mode 100644 index 0000000000..63683a6849 --- /dev/null +++ b/browser/components/asrouter/tests/unit/MomentsPageHub.test.js @@ -0,0 +1,336 @@ +import { GlobalOverrider } from "test/unit/utils"; +import { PanelTestProvider } from "modules/PanelTestProvider.sys.mjs"; +import { _MomentsPageHub } from "modules/MomentsPageHub.sys.mjs"; +const HOMEPAGE_OVERRIDE_PREF = "browser.startup.homepage_override.once"; + +describe("MomentsPageHub", () => { + let globals; + let sandbox; + let instance; + let handleMessageRequestStub; + let addImpressionStub; + let blockMessageByIdStub; + let sendTelemetryStub; + let getStringPrefStub; + let setStringPrefStub; + let setIntervalStub; + let clearIntervalStub; + + beforeEach(async () => { + globals = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + instance = new _MomentsPageHub(); + const messages = (await PanelTestProvider.getMessages()).filter( + ({ template }) => template === "update_action" + ); + handleMessageRequestStub = sandbox.stub().resolves(messages); + addImpressionStub = sandbox.stub(); + blockMessageByIdStub = sandbox.stub(); + getStringPrefStub = sandbox.stub(); + setStringPrefStub = sandbox.stub(); + setIntervalStub = sandbox.stub(); + clearIntervalStub = sandbox.stub(); + sendTelemetryStub = sandbox.stub(); + globals.set({ + setInterval: setIntervalStub, + clearInterval: clearIntervalStub, + Services: { + prefs: { + getStringPref: getStringPrefStub, + setStringPref: setStringPrefStub, + }, + telemetry: { + recordEvent: () => {}, + }, + }, + }); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + it("should create an instance", async () => { + setIntervalStub.returns(42); + assert.ok(instance); + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + assert.equal(instance.state._intervalId, 42); + }); + + it("should init only once", async () => { + assert.notCalled(handleMessageRequestStub); + + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + + assert.calledOnce(handleMessageRequestStub); + + instance.uninit(); + + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + + assert.calledTwice(handleMessageRequestStub); + }); + + it("should uninit the instance", () => { + instance.uninit(); + assert.calledOnce(clearIntervalStub); + }); + + it("should setInterval for `checkHomepageOverridePref`", async () => { + await instance.init(sandbox.stub().resolves(), {}); + sandbox.stub(instance, "checkHomepageOverridePref"); + + assert.calledOnce(setIntervalStub); + assert.calledWithExactly(setIntervalStub, sinon.match.func, 5 * 60 * 1000); + + assert.notCalled(instance.checkHomepageOverridePref); + const [cb] = setIntervalStub.firstCall.args; + + cb(); + + assert.calledOnce(instance.checkHomepageOverridePref); + }); + + describe("#messageRequest", () => { + beforeEach(async () => { + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + sendTelemetry: sendTelemetryStub, + }); + }); + afterEach(() => { + instance.uninit(); + }); + it("should fetch a message with the provided trigger and template", async () => { + await instance.messageRequest({ + triggerId: "trigger", + template: "template", + }); + + assert.calledTwice(handleMessageRequestStub); + assert.calledWithExactly(handleMessageRequestStub, { + triggerId: "trigger", + template: "template", + returnAll: true, + }); + }); + it("shouldn't do anything if no message is provided", async () => { + // Reset the call from `instance.init` + setStringPrefStub.reset(); + handleMessageRequestStub.resolves([]); + await instance.messageRequest({ triggerId: "trigger" }); + + assert.notCalled(setStringPrefStub); + }); + it("should record telemetry events", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + assert.calledOnce(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + }); + it("should record Reach event for the Moments page experiment", async () => { + const momentsMessages = (await PanelTestProvider.getMessages()).filter( + ({ template }) => template === "update_action" + ); + const messages = [ + { + forReachEvent: { sent: false }, + experimentSlug: "foo", + branchSlug: "bar", + }, + ...momentsMessages, + ]; + handleMessageRequestStub.resolves(messages); + sandbox.spy(global.Services.telemetry, "recordEvent"); + sandbox.spy(instance, "executeAction"); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(global.Services.telemetry.recordEvent); + assert.calledOnce(instance.executeAction); + }); + it("should not record the Reach event if it's already sent", async () => { + const messages = [ + { + forReachEvent: { sent: true }, + experimentSlug: "foo", + branchSlug: "bar", + }, + ]; + handleMessageRequestStub.resolves(messages); + sandbox.spy(global.Services.telemetry, "recordEvent"); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.notCalled(global.Services.telemetry.recordEvent); + }); + it("should not trigger the action if it's only for the Reach event", async () => { + const messages = [ + { + forReachEvent: { sent: false }, + experimentSlug: "foo", + branchSlug: "bar", + }, + ]; + handleMessageRequestStub.resolves(messages); + sandbox.spy(global.Services.telemetry, "recordEvent"); + sandbox.spy(instance, "executeAction"); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(global.Services.telemetry.recordEvent); + assert.notCalled(instance.executeAction); + }); + }); + describe("executeAction", () => { + beforeEach(async () => { + blockMessageByIdStub = sandbox.stub(); + await instance.init(sandbox.stub().resolves(), { + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + sendTelemetry: sendTelemetryStub, + }); + }); + it("should set HOMEPAGE_OVERRIDE_PREF on `moments-wnp` action", async () => { + const [msg] = await handleMessageRequestStub(); + sandbox.useFakeTimers(); + instance.executeAction(msg); + + assert.calledOnce(setStringPrefStub); + assert.calledWithExactly( + setStringPrefStub, + HOMEPAGE_OVERRIDE_PREF, + JSON.stringify({ + message_id: msg.id, + url: msg.content.action.data.url, + expire: instance.getExpirationDate( + msg.content.action.data.expireDelta + ), + }) + ); + }); + it("should block after taking the action", async () => { + const [msg] = await handleMessageRequestStub(); + instance.executeAction(msg); + + assert.calledOnce(blockMessageByIdStub); + assert.calledWithExactly(blockMessageByIdStub, msg.id); + }); + it("should compute expire based on expireDelta", async () => { + sandbox.spy(instance, "getExpirationDate"); + + const [msg] = await handleMessageRequestStub(); + instance.executeAction(msg); + + assert.calledOnce(instance.getExpirationDate); + assert.calledWithExactly( + instance.getExpirationDate, + msg.content.action.data.expireDelta + ); + }); + it("should compute expire based on expireDelta", async () => { + sandbox.spy(instance, "getExpirationDate"); + + const [msg] = await handleMessageRequestStub(); + const msgWithExpire = { + ...msg, + content: { + ...msg.content, + action: { + ...msg.content.action, + data: { ...msg.content.action.data, expire: 41 }, + }, + }, + }; + instance.executeAction(msgWithExpire); + + assert.notCalled(instance.getExpirationDate); + assert.calledOnce(setStringPrefStub); + assert.calledWithExactly( + setStringPrefStub, + HOMEPAGE_OVERRIDE_PREF, + JSON.stringify({ + message_id: msg.id, + url: msg.content.action.data.url, + expire: 41, + }) + ); + }); + it("should send user telemetry", async () => { + const [msg] = await handleMessageRequestStub(); + const sendUserEventTelemetrySpy = sandbox.spy( + instance, + "sendUserEventTelemetry" + ); + instance.executeAction(msg); + + assert.calledOnce(sendTelemetryStub); + assert.calledWithExactly(sendUserEventTelemetrySpy, msg); + assert.calledWithExactly(sendTelemetryStub, { + type: "MOMENTS_PAGE_TELEMETRY", + data: { + action: "moments_user_event", + bucket_id: "WNP_THANK_YOU", + event: "MOMENTS_PAGE_SET", + message_id: "WNP_THANK_YOU", + }, + }); + }); + }); + describe("#checkHomepageOverridePref", () => { + let messageRequestStub; + beforeEach(() => { + messageRequestStub = sandbox.stub(instance, "messageRequest"); + }); + it("should catch parse errors", () => { + getStringPrefStub.returns({}); + + instance.checkHomepageOverridePref(); + + assert.calledOnce(messageRequestStub); + assert.calledWithExactly(messageRequestStub, { + template: "update_action", + triggerId: "momentsUpdate", + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/RemoteL10n.test.js b/browser/components/asrouter/tests/unit/RemoteL10n.test.js new file mode 100644 index 0000000000..dd0f858750 --- /dev/null +++ b/browser/components/asrouter/tests/unit/RemoteL10n.test.js @@ -0,0 +1,217 @@ +import { RemoteL10n, _RemoteL10n } from "modules/RemoteL10n.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("RemoteL10n", () => { + let sandbox; + let globals; + let domL10nStub; + let l10nRegStub; + let l10nRegInstance; + let fileSourceStub; + beforeEach(() => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + domL10nStub = sandbox.stub(); + l10nRegInstance = { + hasSource: sandbox.stub(), + registerSources: sandbox.stub(), + removeSources: sandbox.stub(), + }; + + fileSourceStub = sandbox.stub(); + l10nRegStub = { + getInstance: () => { + return l10nRegInstance; + }, + }; + globals.set("DOMLocalization", domL10nStub); + globals.set("L10nRegistry", l10nRegStub); + globals.set("L10nFileSource", fileSourceStub); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + describe("#RemoteL10n", () => { + it("should create a new instance", () => { + assert.ok(new _RemoteL10n()); + }); + it("should create a DOMLocalization instance", () => { + domL10nStub.returns({ instance: true }); + const instance = new _RemoteL10n(); + + assert.propertyVal(instance._createDOML10n(), "instance", true); + assert.calledOnce(domL10nStub); + }); + it("should create a new instance", () => { + domL10nStub.returns({ instance: true }); + const instance = new _RemoteL10n(); + + assert.ok(instance.l10n); + + instance.reloadL10n(); + + assert.ok(instance.l10n); + + assert.calledTwice(domL10nStub); + }); + it("should reuse the instance", () => { + domL10nStub.returns({ instance: true }); + const instance = new _RemoteL10n(); + + assert.ok(instance.l10n); + assert.ok(instance.l10n); + + assert.calledOnce(domL10nStub); + }); + }); + describe("#_createDOML10n", () => { + it("should load the remote Fluent file if USE_REMOTE_L10N_PREF is true", async () => { + sandbox.stub(global.Services.prefs, "getBoolPref").returns(true); + l10nRegInstance.hasSource.returns(false); + RemoteL10n._createDOML10n(); + + assert.calledOnce(domL10nStub); + const { args } = domL10nStub.firstCall; + // The first arg is the resource array, + // the second one is false (use async), + // and the third one is the bundle generator. + assert.equal(args.length, 2); + assert.deepEqual(args[0], [ + "branding/brand.ftl", + "browser/defaultBrowserNotification.ftl", + "browser/newtab/asrouter.ftl", + "toolkit/branding/accounts.ftl", + "toolkit/branding/brandings.ftl", + ]); + assert.isFalse(args[1]); + assert.calledOnce(l10nRegInstance.hasSource); + assert.calledOnce(l10nRegInstance.registerSources); + assert.notCalled(l10nRegInstance.removeSources); + }); + it("should load the local Fluent file if USE_REMOTE_L10N_PREF is false", () => { + sandbox.stub(global.Services.prefs, "getBoolPref").returns(false); + l10nRegInstance.hasSource.returns(true); + RemoteL10n._createDOML10n(); + + const { args } = domL10nStub.firstCall; + // The first arg is the resource array, + // the second one is false (use async), + // and the third one is null. + assert.equal(args.length, 2); + assert.deepEqual(args[0], [ + "branding/brand.ftl", + "browser/defaultBrowserNotification.ftl", + "browser/newtab/asrouter.ftl", + "toolkit/branding/accounts.ftl", + "toolkit/branding/brandings.ftl", + ]); + assert.isFalse(args[1]); + assert.calledOnce(l10nRegInstance.hasSource); + assert.notCalled(l10nRegInstance.registerSources); + assert.calledOnce(l10nRegInstance.removeSources); + }); + }); + describe("#createElement", () => { + let doc; + let instance; + let setStringStub; + let elem; + beforeEach(() => { + elem = document.createElement("div"); + doc = { + createElement: sandbox.stub().returns(elem), + createElementNS: sandbox.stub().returns(elem), + }; + instance = new _RemoteL10n(); + setStringStub = sandbox.stub(instance, "setString"); + }); + it("should call createElement if string_id is defined", () => { + instance.createElement(doc, "span", { content: { string_id: "foo" } }); + + assert.calledOnce(doc.createElement); + }); + it("should call createElementNS if string_id is not present", () => { + instance.createElement(doc, "span", { content: "foo" }); + + assert.calledOnce(doc.createElementNS); + }); + it("should set classList", () => { + instance.createElement(doc, "span", { classList: "foo" }); + + assert.isTrue(elem.classList.contains("foo")); + }); + it("should call setString", () => { + const options = { classList: "foo" }; + instance.createElement(doc, "span", options); + + assert.calledOnce(setStringStub); + assert.calledWithExactly(setStringStub, elem, options); + }); + }); + describe("#setString", () => { + let instance; + beforeEach(() => { + instance = new _RemoteL10n(); + }); + it("should set fluent variables and id", () => { + let el = { setAttribute: sandbox.stub() }; + instance.setString(el, { + content: { string_id: "foo" }, + attributes: { bar: "bar", baz: "baz" }, + }); + + assert.calledThrice(el.setAttribute); + assert.calledWithExactly(el.setAttribute, "fluent-variable-bar", "bar"); + assert.calledWithExactly(el.setAttribute, "fluent-variable-baz", "baz"); + assert.calledWithExactly(el.setAttribute, "fluent-remote-id", "foo"); + }); + it("should set content if no string_id", () => { + let el = { setAttribute: sandbox.stub() }; + instance.setString(el, { content: "foo" }); + + assert.notCalled(el.setAttribute); + assert.equal(el.textContent, "foo"); + }); + }); + describe("#isLocaleSupported", () => { + it("should return true if the locale is en-US", () => { + assert.ok(RemoteL10n.isLocaleSupported("en-US")); + }); + it("should return true if the locale is in all-locales", () => { + assert.ok(RemoteL10n.isLocaleSupported("en-CA")); + }); + it("should return false if the locale is not in all-locales", () => { + assert.ok(!RemoteL10n.isLocaleSupported("und")); + }); + }); + describe("#formatLocalizableText", () => { + let instance; + let formatValueStub; + beforeEach(() => { + instance = new _RemoteL10n(); + formatValueStub = sandbox.stub(); + sandbox + .stub(instance, "l10n") + .get(() => ({ formatValue: formatValueStub })); + }); + it("should localize a string_id", async () => { + formatValueStub.resolves("VALUE"); + + assert.equal( + await instance.formatLocalizableText({ string_id: "ID" }), + "VALUE" + ); + assert.calledOnce(formatValueStub); + }); + it("should pass through a string", async () => { + formatValueStub.reset(); + + assert.equal( + await instance.formatLocalizableText("unchanged"), + "unchanged" + ); + assert.isFalse(formatValueStub.called); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/TargetingDocs.test.js b/browser/components/asrouter/tests/unit/TargetingDocs.test.js new file mode 100644 index 0000000000..d00f971453 --- /dev/null +++ b/browser/components/asrouter/tests/unit/TargetingDocs.test.js @@ -0,0 +1,88 @@ +import { ASRouterTargeting } from "modules/ASRouterTargeting.sys.mjs"; +import docs from "docs/targeting-attributes.md"; + +// The following targeting parameters are either deprecated or should not be included in the docs for some reason. +const SKIP_DOCS = []; +// These are extra message context attributes via ASRouter.sys.mjs +const MESSAGE_CONTEXT_ATTRIBUTES = ["previousSessionEnd"]; + +function getHeadingsFromDocs() { + const re = /### `(\w+)`/g; + const found = []; + let match = 1; + while (match) { + match = re.exec(docs); + if (match) { + found.push(match[1]); + } + } + return found; +} + +function getTOCFromDocs() { + const re = /## Available attributes\n+([^]+)\n+## Detailed usage/; + const sectionMatch = docs.match(re); + if (!sectionMatch) { + return []; + } + const [, listText] = sectionMatch; + const re2 = /\[(\w+)\]/g; + const found = []; + let match = 1; + while (match) { + match = re2.exec(listText); + if (match) { + found.push(match[1]); + } + } + return found; +} + +describe("ASRTargeting docs", () => { + const DOCS_TARGETING_HEADINGS = getHeadingsFromDocs(); + const DOCS_TOC = getTOCFromDocs(); + const ASRTargetingAttributes = [ + ...Object.keys(ASRouterTargeting.Environment).filter( + attribute => !SKIP_DOCS.includes(attribute) + ), + ...MESSAGE_CONTEXT_ATTRIBUTES, + ]; + + describe("All targeting params documented in targeting-attributes.md", () => { + for (const targetingParam of ASRTargetingAttributes) { + // If this test is failing, you probably forgot to add docs to content-src/asrouter/targeting-attributes.md + // for a new targeting attribute, or you forgot to put it in the table of contents up top. + it(`should have docs and table of contents entry for ${targetingParam}`, () => { + assert.include( + DOCS_TARGETING_HEADINGS, + targetingParam, + `Didn't find the heading: ### \`${targetingParam}\`` + ); + assert.include( + DOCS_TOC, + targetingParam, + `Didn't find a table of contents entry for ${targetingParam}` + ); + }); + } + }); + describe("No extra attributes in targeting-attributes.md", () => { + // "allow" includes targeting attributes that are not implemented by + // ASRTargetingAttributes. For example trigger context passed to the evaluation + // context in when a trigger runs or ASRouter state used in the evaluation. + const allow = ["messageImpressions", "screenImpressions"]; + for (const targetingParam of DOCS_TARGETING_HEADINGS.filter( + doc => !allow.includes(doc) + )) { + // If this test is failing, you might have spelled something wrong or removed a targeting param without + // removing its docs. + it(`should have an implementation for ${targetingParam} in ASRouterTargeting.Environment`, () => { + assert.include( + ASRTargetingAttributes, + targetingParam, + `Didn't find an implementation for ${targetingParam}` + ); + }); + } + }); +}); diff --git a/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js b/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js new file mode 100644 index 0000000000..3e91b657bc --- /dev/null +++ b/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js @@ -0,0 +1,652 @@ +import { _ToolbarBadgeHub } from "modules/ToolbarBadgeHub.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; +import { OnboardingMessageProvider } from "modules/OnboardingMessageProvider.sys.mjs"; +import { + _ToolbarPanelHub, + ToolbarPanelHub, +} from "modules/ToolbarPanelHub.sys.mjs"; + +describe("ToolbarBadgeHub", () => { + let sandbox; + let instance; + let fakeAddImpression; + let fakeSendTelemetry; + let isBrowserPrivateStub; + let fxaMessage; + let whatsnewMessage; + let fakeElement; + let globals; + let everyWindowStub; + let clearTimeoutStub; + let setTimeoutStub; + let addObserverStub; + let removeObserverStub; + let getStringPrefStub; + let clearUserPrefStub; + let setStringPrefStub; + let requestIdleCallbackStub; + let fakeWindow; + beforeEach(async () => { + globals = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + instance = new _ToolbarBadgeHub(); + fakeAddImpression = sandbox.stub(); + fakeSendTelemetry = sandbox.stub(); + isBrowserPrivateStub = sandbox.stub(); + const onboardingMsgs = + await OnboardingMessageProvider.getUntranslatedMessages(); + fxaMessage = onboardingMsgs.find(({ id }) => id === "FXA_ACCOUNTS_BADGE"); + whatsnewMessage = { + id: `WHATS_NEW_BADGE_71`, + template: "toolbar_badge", + content: { + delay: 1000, + target: "whats-new-menu-button", + action: { id: "show-whatsnew-button" }, + badgeDescription: { string_id: "cfr-badge-reader-label-newfeature" }, + }, + priority: 1, + trigger: { id: "toolbarBadgeUpdate" }, + frequency: { + // Makes it so that we track impressions for this message while at the + // same time it can have unlimited impressions + lifetime: Infinity, + }, + // Never saw this message or saw it in the past 4 days or more recent + targeting: `isWhatsNewPanelEnabled && + (!messageImpressions['WHATS_NEW_BADGE_71'] || + (messageImpressions['WHATS_NEW_BADGE_71']|length >= 1 && + currentDate|date - messageImpressions['WHATS_NEW_BADGE_71'][0] <= 4 * 24 * 3600 * 1000))`, + }; + fakeElement = { + classList: { + add: sandbox.stub(), + remove: sandbox.stub(), + }, + setAttribute: sandbox.stub(), + removeAttribute: sandbox.stub(), + querySelector: sandbox.stub(), + addEventListener: sandbox.stub(), + remove: sandbox.stub(), + appendChild: sandbox.stub(), + }; + // Share the same element when selecting child nodes + fakeElement.querySelector.returns(fakeElement); + everyWindowStub = { + registerCallback: sandbox.stub(), + unregisterCallback: sandbox.stub(), + }; + clearTimeoutStub = sandbox.stub(); + setTimeoutStub = sandbox.stub(); + fakeWindow = { + MozXULElement: { insertFTLIfNeeded: sandbox.stub() }, + ownerGlobal: { + gBrowser: { + selectedBrowser: "browser", + }, + }, + }; + addObserverStub = sandbox.stub(); + removeObserverStub = sandbox.stub(); + getStringPrefStub = sandbox.stub(); + clearUserPrefStub = sandbox.stub(); + setStringPrefStub = sandbox.stub(); + requestIdleCallbackStub = sandbox.stub().callsFake(fn => fn()); + globals.set({ + ToolbarPanelHub, + requestIdleCallback: requestIdleCallbackStub, + EveryWindow: everyWindowStub, + PrivateBrowsingUtils: { isBrowserPrivate: isBrowserPrivateStub }, + setTimeout: setTimeoutStub, + clearTimeout: clearTimeoutStub, + Services: { + wm: { + getMostRecentWindow: () => fakeWindow, + }, + prefs: { + addObserver: addObserverStub, + removeObserver: removeObserverStub, + getStringPref: getStringPrefStub, + clearUserPref: clearUserPrefStub, + setStringPref: setStringPrefStub, + }, + }, + }); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + it("should create an instance", () => { + assert.ok(instance); + }); + describe("#init", () => { + it("should make a single messageRequest on init", async () => { + sandbox.stub(instance, "messageRequest"); + const waitForInitialized = sandbox.stub().resolves(); + + await instance.init(waitForInitialized, {}); + await instance.init(waitForInitialized, {}); + assert.calledOnce(instance.messageRequest); + assert.calledWithExactly(instance.messageRequest, { + template: "toolbar_badge", + triggerId: "toolbarBadgeUpdate", + }); + + instance.uninit(); + + await instance.init(waitForInitialized, {}); + + assert.calledTwice(instance.messageRequest); + }); + it("should add a pref observer", async () => { + await instance.init(sandbox.stub().resolves(), {}); + + assert.calledOnce(addObserverStub); + assert.calledWithExactly( + addObserverStub, + instance.prefs.WHATSNEW_TOOLBAR_PANEL, + instance + ); + }); + }); + describe("#uninit", () => { + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), {}); + }); + it("should clear any setTimeout cbs", async () => { + await instance.init(sandbox.stub().resolves(), {}); + + instance.state.showBadgeTimeoutId = 2; + + instance.uninit(); + + assert.calledOnce(clearTimeoutStub); + assert.calledWithExactly(clearTimeoutStub, 2); + }); + it("should remove the pref observer", () => { + instance.uninit(); + + assert.calledOnce(removeObserverStub); + assert.calledWithExactly( + removeObserverStub, + instance.prefs.WHATSNEW_TOOLBAR_PANEL, + instance + ); + }); + }); + describe("messageRequest", () => { + let handleMessageRequestStub; + beforeEach(() => { + handleMessageRequestStub = sandbox.stub().returns(fxaMessage); + sandbox + .stub(instance, "_handleMessageRequest") + .value(handleMessageRequestStub); + sandbox.stub(instance, "registerBadgeNotificationListener"); + }); + it("should fetch a message with the provided trigger and template", async () => { + await instance.messageRequest({ + triggerId: "trigger", + template: "template", + }); + + assert.calledOnce(handleMessageRequestStub); + assert.calledWithExactly(handleMessageRequestStub, { + triggerId: "trigger", + template: "template", + }); + }); + it("should call addToolbarNotification with browser window and message", async () => { + await instance.messageRequest("trigger"); + + assert.calledOnce(instance.registerBadgeNotificationListener); + assert.calledWithExactly( + instance.registerBadgeNotificationListener, + fxaMessage + ); + }); + it("shouldn't do anything if no message is provided", async () => { + handleMessageRequestStub.resolves(null); + await instance.messageRequest({ triggerId: "trigger" }); + + assert.notCalled(instance.registerBadgeNotificationListener); + }); + it("should record telemetry events", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + handleMessageRequestStub.returns(null); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + assert.calledOnce(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + }); + }); + describe("addToolbarNotification", () => { + let target; + let fakeDocument; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + addImpression: fakeAddImpression, + sendTelemetry: fakeSendTelemetry, + }); + fakeDocument = { + getElementById: sandbox.stub().returns(fakeElement), + createElement: sandbox.stub().returns(fakeElement), + l10n: { setAttributes: sandbox.stub() }, + }; + target = { ...fakeWindow, browser: { ownerDocument: fakeDocument } }; + }); + afterEach(() => { + instance.uninit(); + }); + it("shouldn't do anything if target element is not found", () => { + fakeDocument.getElementById.returns(null); + instance.addToolbarNotification(target, fxaMessage); + + assert.notCalled(fakeElement.setAttribute); + }); + it("should target the element specified in the message", () => { + instance.addToolbarNotification(target, fxaMessage); + + assert.calledOnce(fakeDocument.getElementById); + assert.calledWithExactly( + fakeDocument.getElementById, + fxaMessage.content.target + ); + }); + it("should show a notification", () => { + instance.addToolbarNotification(target, fxaMessage); + + assert.calledOnce(fakeElement.setAttribute); + assert.calledWithExactly(fakeElement.setAttribute, "badged", true); + assert.calledWithExactly(fakeElement.classList.add, "feature-callout"); + }); + it("should attach a cb on the notification", () => { + instance.addToolbarNotification(target, fxaMessage); + + assert.calledTwice(fakeElement.addEventListener); + assert.calledWithExactly( + fakeElement.addEventListener, + "mousedown", + instance.removeAllNotifications + ); + assert.calledWithExactly( + fakeElement.addEventListener, + "keypress", + instance.removeAllNotifications + ); + }); + it("should execute actions if they exist", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(instance.executeAction); + assert.calledWithExactly(instance.executeAction, { + ...whatsnewMessage.content.action, + message_id: whatsnewMessage.id, + }); + }); + it("should create a description element", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(fakeDocument.createElement); + assert.calledWithExactly(fakeDocument.createElement, "span"); + }); + it("should set description id to element and to button", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledWithExactly( + fakeElement.setAttribute, + "id", + "toolbarbutton-notification-description" + ); + assert.calledWithExactly( + fakeElement.setAttribute, + "aria-labelledby", + `toolbarbutton-notification-description ${whatsnewMessage.content.target}` + ); + }); + it("should attach fluent id to description", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(fakeDocument.l10n.setAttributes); + assert.calledWithExactly( + fakeDocument.l10n.setAttributes, + fakeElement, + whatsnewMessage.content.badgeDescription.string_id + ); + }); + it("should add an impression for the message", () => { + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(instance._addImpression); + assert.calledWithExactly(instance._addImpression, whatsnewMessage); + }); + it("should send an impression ping", async () => { + sandbox.stub(instance, "sendUserEventTelemetry"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(instance.sendUserEventTelemetry); + assert.calledWithExactly( + instance.sendUserEventTelemetry, + "IMPRESSION", + whatsnewMessage + ); + }); + }); + describe("registerBadgeNotificationListener", () => { + let msg_no_delay; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + addImpression: fakeAddImpression, + sendTelemetry: fakeSendTelemetry, + }); + sandbox.stub(instance, "addToolbarNotification").returns(fakeElement); + sandbox.stub(instance, "removeToolbarNotification"); + msg_no_delay = { + ...fxaMessage, + content: { + ...fxaMessage.content, + delay: 0, + }, + }; + }); + afterEach(() => { + instance.uninit(); + }); + it("should register a callback that adds/removes the notification", () => { + instance.registerBadgeNotificationListener(msg_no_delay); + + assert.calledOnce(everyWindowStub.registerCallback); + assert.calledWithExactly( + everyWindowStub.registerCallback, + instance.id, + sinon.match.func, + sinon.match.func + ); + + const [, initFn, uninitFn] = + everyWindowStub.registerCallback.firstCall.args; + + initFn(window); + // Test that it doesn't try to add a second notification + initFn(window); + + assert.calledOnce(instance.addToolbarNotification); + assert.calledWithExactly( + instance.addToolbarNotification, + window, + msg_no_delay + ); + + uninitFn(window); + + assert.calledOnce(instance.removeToolbarNotification); + assert.calledWithExactly(instance.removeToolbarNotification, fakeElement); + }); + it("should unregister notifications when forcing a badge via devtools", () => { + instance.registerBadgeNotificationListener(msg_no_delay, { force: true }); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id); + }); + it("should only call executeAction for 'update_action' messages", () => { + const stub = sandbox.stub(instance, "executeAction"); + const updateActionMsg = { ...msg_no_delay, template: "update_action" }; + + instance.registerBadgeNotificationListener(updateActionMsg); + + assert.notCalled(everyWindowStub.registerCallback); + assert.calledOnce(stub); + }); + }); + describe("executeAction", () => { + let blockMessageByIdStub; + beforeEach(async () => { + blockMessageByIdStub = sandbox.stub(); + await instance.init(sandbox.stub().resolves(), { + blockMessageById: blockMessageByIdStub, + }); + }); + it("should call ToolbarPanelHub.enableToolbarButton", () => { + const stub = sandbox.stub( + _ToolbarPanelHub.prototype, + "enableToolbarButton" + ); + + instance.executeAction({ id: "show-whatsnew-button" }); + + assert.calledOnce(stub); + }); + it("should call ToolbarPanelHub.enableAppmenuButton", () => { + const stub = sandbox.stub( + _ToolbarPanelHub.prototype, + "enableAppmenuButton" + ); + + instance.executeAction({ id: "show-whatsnew-button" }); + + assert.calledOnce(stub); + }); + }); + describe("removeToolbarNotification", () => { + it("should remove the notification", () => { + instance.removeToolbarNotification(fakeElement); + + assert.calledThrice(fakeElement.removeAttribute); + assert.calledWithExactly(fakeElement.removeAttribute, "badged"); + assert.calledWithExactly(fakeElement.removeAttribute, "aria-labelledby"); + assert.calledWithExactly(fakeElement.removeAttribute, "aria-describedby"); + assert.calledOnce(fakeElement.classList.remove); + assert.calledWithExactly(fakeElement.classList.remove, "feature-callout"); + assert.calledOnce(fakeElement.remove); + }); + }); + describe("removeAllNotifications", () => { + let blockMessageByIdStub; + let fakeEvent; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + sendTelemetry: fakeSendTelemetry, + }); + blockMessageByIdStub = sandbox.stub(); + sandbox.stub(instance, "_blockMessageById").value(blockMessageByIdStub); + instance.state = { notification: { id: fxaMessage.id } }; + fakeEvent = { target: { removeEventListener: sandbox.stub() } }; + }); + it("should call to block the message", () => { + instance.removeAllNotifications(); + + assert.calledOnce(blockMessageByIdStub); + assert.calledWithExactly(blockMessageByIdStub, fxaMessage.id); + }); + it("should remove the window listener", () => { + instance.removeAllNotifications(); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id); + }); + it("should ignore right mouse button (mousedown event)", () => { + fakeEvent.type = "mousedown"; + fakeEvent.button = 1; // not left click + + instance.removeAllNotifications(fakeEvent); + + assert.notCalled(fakeEvent.target.removeEventListener); + assert.notCalled(everyWindowStub.unregisterCallback); + }); + it("should ignore right mouse button (click event)", () => { + fakeEvent.type = "click"; + fakeEvent.button = 1; // not left click + + instance.removeAllNotifications(fakeEvent); + + assert.notCalled(fakeEvent.target.removeEventListener); + assert.notCalled(everyWindowStub.unregisterCallback); + }); + it("should ignore keypresses that are not meant to focus the target", () => { + fakeEvent.type = "keypress"; + fakeEvent.key = "\t"; // not enter + + instance.removeAllNotifications(fakeEvent); + + assert.notCalled(fakeEvent.target.removeEventListener); + assert.notCalled(everyWindowStub.unregisterCallback); + }); + it("should remove the event listeners after succesfully focusing the element", () => { + fakeEvent.type = "click"; + fakeEvent.button = 0; + + instance.removeAllNotifications(fakeEvent); + + assert.calledTwice(fakeEvent.target.removeEventListener); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "mousedown", + instance.removeAllNotifications + ); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "keypress", + instance.removeAllNotifications + ); + }); + it("should send telemetry", () => { + fakeEvent.type = "click"; + fakeEvent.button = 0; + sandbox.stub(instance, "sendUserEventTelemetry"); + + instance.removeAllNotifications(fakeEvent); + + assert.calledOnce(instance.sendUserEventTelemetry); + assert.calledWithExactly(instance.sendUserEventTelemetry, "CLICK", { + id: "FXA_ACCOUNTS_BADGE", + }); + }); + it("should remove the event listeners after succesfully focusing the element", () => { + fakeEvent.type = "keypress"; + fakeEvent.key = "Enter"; + + instance.removeAllNotifications(fakeEvent); + + assert.calledTwice(fakeEvent.target.removeEventListener); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "mousedown", + instance.removeAllNotifications + ); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "keypress", + instance.removeAllNotifications + ); + }); + }); + describe("message with delay", () => { + let msg_with_delay; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + addImpression: fakeAddImpression, + }); + msg_with_delay = { + ...fxaMessage, + content: { + ...fxaMessage.content, + delay: 500, + }, + }; + sandbox.stub(instance, "registerBadgeToAllWindows"); + }); + afterEach(() => { + instance.uninit(); + }); + it("should register a cb to fire after msg.content.delay ms", () => { + instance.registerBadgeNotificationListener(msg_with_delay); + + assert.calledOnce(setTimeoutStub); + assert.calledWithExactly( + setTimeoutStub, + sinon.match.func, + msg_with_delay.content.delay + ); + + const [cb] = setTimeoutStub.firstCall.args; + + assert.notCalled(instance.registerBadgeToAllWindows); + + cb(); + + assert.calledOnce(instance.registerBadgeToAllWindows); + assert.calledWithExactly( + instance.registerBadgeToAllWindows, + msg_with_delay + ); + // Delayed actions should be executed inside requestIdleCallback + assert.calledOnce(requestIdleCallbackStub); + }); + }); + describe("#sendUserEventTelemetry", () => { + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + sendTelemetry: fakeSendTelemetry, + }); + }); + it("should check for private window and not send", () => { + isBrowserPrivateStub.returns(true); + + instance.sendUserEventTelemetry("CLICK", { id: fxaMessage }); + + assert.notCalled(instance._sendTelemetry); + }); + it("should check for private window and send", () => { + isBrowserPrivateStub.returns(false); + + instance.sendUserEventTelemetry("CLICK", { id: fxaMessage }); + + assert.calledOnce(fakeSendTelemetry); + const [ping] = instance._sendTelemetry.firstCall.args; + assert.propertyVal(ping, "type", "TOOLBAR_BADGE_TELEMETRY"); + assert.propertyVal(ping.data, "event", "CLICK"); + }); + }); + describe("#observe", () => { + it("should make a message request when the whats new pref is changed", () => { + sandbox.stub(instance, "messageRequest"); + + instance.observe("", "", instance.prefs.WHATSNEW_TOOLBAR_PANEL); + + assert.calledOnce(instance.messageRequest); + assert.calledWithExactly(instance.messageRequest, { + template: "toolbar_badge", + triggerId: "toolbarBadgeUpdate", + }); + }); + it("should not react to other pref changes", () => { + sandbox.stub(instance, "messageRequest"); + + instance.observe("", "", "foo"); + + assert.notCalled(instance.messageRequest); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js b/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js new file mode 100644 index 0000000000..64cb8243b7 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js @@ -0,0 +1,762 @@ +import { _ToolbarPanelHub } from "modules/ToolbarPanelHub.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; +import { PanelTestProvider } from "modules/PanelTestProvider.sys.mjs"; + +describe("ToolbarPanelHub", () => { + let globals; + let sandbox; + let instance; + let everyWindowStub; + let fakeDocument; + let fakeWindow; + let fakeElementById; + let fakeElementByTagName; + let createdCustomElements = []; + let eventListeners = {}; + let addObserverStub; + let removeObserverStub; + let getBoolPrefStub; + let setBoolPrefStub; + let waitForInitializedStub; + let isBrowserPrivateStub; + let fakeSendTelemetry; + let getEarliestRecordedDateStub; + let getEventsByDateRangeStub; + let defaultSearchStub; + let scriptloaderStub; + let fakeRemoteL10n; + let getViewNodeStub; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + instance = new _ToolbarPanelHub(); + waitForInitializedStub = sandbox.stub().resolves(); + fakeElementById = { + setAttribute: sandbox.stub(), + removeAttribute: sandbox.stub(), + querySelector: sandbox.stub().returns(null), + querySelectorAll: sandbox.stub().returns([]), + appendChild: sandbox.stub(), + addEventListener: sandbox.stub(), + hasAttribute: sandbox.stub(), + toggleAttribute: sandbox.stub(), + remove: sandbox.stub(), + removeChild: sandbox.stub(), + }; + fakeElementByTagName = { + setAttribute: sandbox.stub(), + removeAttribute: sandbox.stub(), + querySelector: sandbox.stub().returns(null), + querySelectorAll: sandbox.stub().returns([]), + appendChild: sandbox.stub(), + addEventListener: sandbox.stub(), + hasAttribute: sandbox.stub(), + toggleAttribute: sandbox.stub(), + remove: sandbox.stub(), + removeChild: sandbox.stub(), + }; + fakeDocument = { + getElementById: sandbox.stub().returns(fakeElementById), + getElementsByTagName: sandbox.stub().returns(fakeElementByTagName), + querySelector: sandbox.stub().returns({}), + createElement: tagName => { + const element = { + tagName, + classList: {}, + addEventListener: (ev, fn) => { + eventListeners[ev] = fn; + }, + appendChild: sandbox.stub(), + setAttribute: sandbox.stub(), + textContent: "", + }; + element.classList.add = sandbox.stub(); + element.classList.includes = className => + element.classList.add.firstCall.args[0] === className; + createdCustomElements.push(element); + return element; + }, + l10n: { + translateElements: sandbox.stub(), + translateFragment: sandbox.stub(), + formatMessages: sandbox.stub().resolves([{}]), + setAttributes: sandbox.stub(), + }, + }; + fakeWindow = { + // eslint-disable-next-line object-shorthand + DocumentFragment: function () { + return fakeElementById; + }, + document: fakeDocument, + browser: { + ownerDocument: fakeDocument, + }, + MozXULElement: { insertFTLIfNeeded: sandbox.stub() }, + ownerGlobal: { + openLinkIn: sandbox.stub(), + gBrowser: "gBrowser", + }, + PanelUI: { + panel: fakeElementById, + whatsNewPanel: fakeElementById, + }, + customElements: { get: sandbox.stub() }, + }; + everyWindowStub = { + registerCallback: sandbox.stub(), + unregisterCallback: sandbox.stub(), + }; + scriptloaderStub = { loadSubScript: sandbox.stub() }; + addObserverStub = sandbox.stub(); + removeObserverStub = sandbox.stub(); + getBoolPrefStub = sandbox.stub(); + setBoolPrefStub = sandbox.stub(); + fakeSendTelemetry = sandbox.stub(); + isBrowserPrivateStub = sandbox.stub(); + getEarliestRecordedDateStub = sandbox.stub().returns( + // A random date that's not the current timestamp + new Date() - 500 + ); + getEventsByDateRangeStub = sandbox.stub().returns([]); + getViewNodeStub = sandbox.stub().returns(fakeElementById); + defaultSearchStub = { defaultEngine: { name: "DDG" } }; + fakeRemoteL10n = { + l10n: {}, + reloadL10n: sandbox.stub(), + createElement: sandbox + .stub() + .callsFake((doc, el) => fakeDocument.createElement(el)), + }; + globals.set({ + EveryWindow: everyWindowStub, + Services: { + ...Services, + prefs: { + addObserver: addObserverStub, + removeObserver: removeObserverStub, + getBoolPref: getBoolPrefStub, + setBoolPref: setBoolPrefStub, + }, + search: defaultSearchStub, + scriptloader: scriptloaderStub, + }, + PrivateBrowsingUtils: { + isBrowserPrivate: isBrowserPrivateStub, + }, + TrackingDBService: { + getEarliestRecordedDate: getEarliestRecordedDateStub, + getEventsByDateRange: getEventsByDateRangeStub, + }, + SpecialMessageActions: { + handleAction: sandbox.stub(), + }, + RemoteL10n: fakeRemoteL10n, + PanelMultiView: { + getViewNode: getViewNodeStub, + }, + }); + }); + afterEach(() => { + instance.uninit(); + sandbox.restore(); + globals.restore(); + eventListeners = {}; + createdCustomElements = []; + }); + it("should create an instance", () => { + assert.ok(instance); + }); + it("should enableAppmenuButton() on init() just once", async () => { + instance.enableAppmenuButton = sandbox.stub(); + + await instance.init(waitForInitializedStub, { getMessages: () => {} }); + await instance.init(waitForInitializedStub, { getMessages: () => {} }); + + assert.calledOnce(instance.enableAppmenuButton); + + instance.uninit(); + + await instance.init(waitForInitializedStub, { getMessages: () => {} }); + + assert.calledTwice(instance.enableAppmenuButton); + }); + it("should unregisterCallback on uninit()", () => { + instance.uninit(); + assert.calledTwice(everyWindowStub.unregisterCallback); + }); + describe("#maybeLoadCustomElement", () => { + it("should not load customElements a second time", () => { + instance.maybeLoadCustomElement({ customElements: new Map() }); + instance.maybeLoadCustomElement({ + customElements: new Map([["remote-text", true]]), + }); + + assert.calledOnce(scriptloaderStub.loadSubScript); + }); + }); + describe("#toggleWhatsNewPref", () => { + it("should call Services.prefs.setBoolPref() with the opposite value", () => { + let checkbox = {}; + let event = { target: checkbox }; + // checkbox starts false + checkbox.checked = false; + + // toggling the checkbox to set the value to true; + // Preferences.set() gets called before the checkbox changes, + // so we have to call it with the opposite value. + instance.toggleWhatsNewPref(event); + + assert.calledOnce(setBoolPrefStub); + assert.calledWith( + setBoolPrefStub, + "browser.messaging-system.whatsNewPanel.enabled", + !checkbox.checked + ); + }); + it("should report telemetry with the opposite value", () => { + let sendUserEventTelemetryStub = sandbox.stub( + instance, + "sendUserEventTelemetry" + ); + let event = { + target: { checked: true, ownerGlobal: fakeWindow }, + }; + + instance.toggleWhatsNewPref(event); + + assert.calledOnce(sendUserEventTelemetryStub); + const { args } = sendUserEventTelemetryStub.firstCall; + assert.equal(args[1], "WNP_PREF_TOGGLE"); + assert.propertyVal(args[3].value, "prefValue", false); + }); + }); + describe("#enableAppmenuButton", () => { + it("should registerCallback on enableAppmenuButton() if there are messages", async () => { + await instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([{}, {}]), + }); + // init calls `enableAppmenuButton` + everyWindowStub.registerCallback.resetHistory(); + + await instance.enableAppmenuButton(); + + assert.calledOnce(everyWindowStub.registerCallback); + assert.calledWithExactly( + everyWindowStub.registerCallback, + "appMenu-whatsnew-button", + sinon.match.func, + sinon.match.func + ); + }); + it("should not registerCallback on enableAppmenuButton() if there are no messages", async () => { + instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([]), + }); + // init calls `enableAppmenuButton` + everyWindowStub.registerCallback.resetHistory(); + + await instance.enableAppmenuButton(); + + assert.notCalled(everyWindowStub.registerCallback); + }); + }); + describe("#disableAppmenuButton", () => { + it("should call the unregisterCallback", () => { + assert.notCalled(everyWindowStub.unregisterCallback); + + instance.disableAppmenuButton(); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly( + everyWindowStub.unregisterCallback, + "appMenu-whatsnew-button" + ); + }); + }); + describe("#enableToolbarButton", () => { + it("should registerCallback on enableToolbarButton if messages.length", async () => { + await instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([{}, {}]), + }); + // init calls `enableAppmenuButton` + everyWindowStub.registerCallback.resetHistory(); + + await instance.enableToolbarButton(); + + assert.calledOnce(everyWindowStub.registerCallback); + assert.calledWithExactly( + everyWindowStub.registerCallback, + "whats-new-menu-button", + sinon.match.func, + sinon.match.func + ); + }); + it("should not registerCallback on enableToolbarButton if no messages", async () => { + await instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([]), + }); + + await instance.enableToolbarButton(); + + assert.notCalled(everyWindowStub.registerCallback); + }); + }); + describe("Show/Hide functions", () => { + it("should unhide appmenu button on _showAppmenuButton()", async () => { + await instance._showAppmenuButton(fakeWindow); + + assert.equal(fakeElementById.hidden, false); + }); + it("should hide appmenu button on _hideAppmenuButton()", () => { + instance._hideAppmenuButton(fakeWindow); + assert.equal(fakeElementById.hidden, true); + }); + it("should not do anything if the window is closed", () => { + instance._hideAppmenuButton(fakeWindow, true); + assert.notCalled(global.PanelMultiView.getViewNode); + }); + it("should not throw if the element does not exist", () => { + let fn = instance._hideAppmenuButton.bind(null, { + browser: { ownerDocument: {} }, + }); + getViewNodeStub.returns(undefined); + assert.doesNotThrow(fn); + }); + it("should unhide toolbar button on _showToolbarButton()", async () => { + await instance._showToolbarButton(fakeWindow); + + assert.equal(fakeElementById.hidden, false); + }); + it("should hide toolbar button on _hideToolbarButton()", () => { + instance._hideToolbarButton(fakeWindow); + assert.equal(fakeElementById.hidden, true); + }); + }); + describe("#renderMessages", () => { + let getMessagesStub; + beforeEach(() => { + getMessagesStub = sandbox.stub(); + instance.init(waitForInitializedStub, { + getMessages: getMessagesStub, + sendTelemetry: fakeSendTelemetry, + }); + }); + it("should have correct state", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + + getMessagesStub.returns(messages); + const ev1 = sandbox.stub(); + ev1.withArgs("type").returns(1); // tracker + ev1.withArgs("count").returns(4); + const ev2 = sandbox.stub(); + ev2.withArgs("type").returns(4); // fingerprinter + ev2.withArgs("count").returns(3); + getEventsByDateRangeStub.returns([ + { getResultByName: ev1 }, + { getResultByName: ev2 }, + ]); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.propertyVal(instance.state.contentArguments, "trackerCount", 4); + assert.propertyVal( + instance.state.contentArguments, + "fingerprinterCount", + 3 + ); + }); + it("should render messages to the panel on renderMessages()", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + messages[0].content.link_text = { string_id: "link_text_id" }; + + getMessagesStub.returns(messages); + const ev1 = sandbox.stub(); + ev1.withArgs("type").returns(1); // tracker + ev1.withArgs("count").returns(4); + const ev2 = sandbox.stub(); + ev2.withArgs("type").returns(4); // fingerprinter + ev2.withArgs("count").returns(3); + getEventsByDateRangeStub.returns([ + { getResultByName: ev1 }, + { getResultByName: ev2 }, + ]); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + for (let message of messages) { + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-title" + ) + ); + if (message.content.layout === "tracking-protections") { + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-subtitle" + ) + ); + } + if (message.id === "WHATS_NEW_FINGERPRINTER_COUNTER_72") { + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => el === "h2" && args.content === 3 + ) + ); + } + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-content" + ) + ); + } + // Call the click handler to make coverage happy. + eventListeners.mouseup(); + assert.calledOnce(global.SpecialMessageActions.handleAction); + }); + it("should clear previous messages on 2nd renderMessages()", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + const removeStub = sandbox.stub(); + fakeElementById.querySelectorAll.onCall(0).returns([]); + fakeElementById.querySelectorAll + .onCall(1) + .returns([{ remove: removeStub }, { remove: removeStub }]); + + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledTwice(removeStub); + }); + it("should sort based on order field value", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => + m.template === "whatsnew_panel_message" && + m.content.published_date === 1560969794394 + ); + + messages.forEach(m => (m.content.title = m.order)); + + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + // Select the title elements that are supposed to be set to the same + // value as the `order` field of the message + const titleEls = fakeRemoteL10n.createElement.args + .filter( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-title" + ) + .map(([doc, el, args]) => args.content); + assert.deepEqual(titleEls, [1, 2, 3]); + }); + it("should accept string for image attributes", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.id === "WHATS_NEW_70_1" + ); + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const imageEl = createdCustomElements.find(el => el.tagName === "img"); + assert.calledOnce(imageEl.setAttribute); + assert.calledWithExactly( + imageEl.setAttribute, + "alt", + "Firefox Send Logo" + ); + }); + it("should set state values as data-attribute", async () => { + const message = (await PanelTestProvider.getMessages()).find( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns([message]); + instance.state.contentArguments = { foo: "foo", bar: "bar" }; + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const [, , args] = fakeRemoteL10n.createElement.args.find( + ([doc, el, elArgs]) => elArgs && elArgs.attributes + ); + assert.ok(args); + // Currently this.state.contentArguments has 8 different entries + assert.lengthOf(Object.keys(args.attributes), 8); + assert.equal( + args.attributes.searchEngineName, + defaultSearchStub.defaultEngine.name + ); + }); + it("should only render unique dates (no duplicates)", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + const uniqueDates = [ + ...new Set(messages.map(m => m.content.published_date)), + ]; + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const dateElements = fakeRemoteL10n.createElement.args.filter( + ([doc, el, args]) => + el === "p" && args.classList === "whatsNew-message-date" + ); + assert.lengthOf(dateElements, uniqueDates.length); + }); + it("should listen for panelhidden and remove the toolbar button", async () => { + getMessagesStub.returns([]); + fakeDocument.getElementById + .withArgs("customizationui-widget-panel") + .returns(null); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.notCalled(fakeElementById.addEventListener); + }); + it("should attach doCommand cbs that handle user actions", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const messageEl = createdCustomElements.find( + el => + el.tagName === "div" && el.classList.includes("whatsNew-message-body") + ); + const anchorEl = createdCustomElements.find(el => el.tagName === "a"); + + assert.notCalled(global.SpecialMessageActions.handleAction); + + messageEl.doCommand(); + anchorEl.doCommand(); + + assert.calledTwice(global.SpecialMessageActions.handleAction); + }); + it("should listen for panelhidden and remove the toolbar button", async () => { + getMessagesStub.returns([]); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(fakeElementById.addEventListener); + assert.calledWithExactly( + fakeElementById.addEventListener, + "popuphidden", + sinon.match.func, + { + once: true, + } + ); + const [, cb] = fakeElementById.addEventListener.firstCall.args; + + assert.notCalled(everyWindowStub.unregisterCallback); + + cb(); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly( + everyWindowStub.unregisterCallback, + "whats-new-menu-button" + ); + }); + describe("#IMPRESSION", () => { + it("should dispatch a IMPRESSION for messages", async () => { + // means panel is triggered from the toolbar button + fakeElementById.hasAttribute.returns(true); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns(messages); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledOnce(fakeSendTelemetry); + assert.propertyVal( + spy.firstCall.args[2], + "id", + messages + .map(({ id }) => id) + .sort() + .join(",") + ); + }); + it("should dispatch a CLICK for clicking a message", async () => { + // means panel is triggered from the toolbar button + fakeElementById.hasAttribute.returns(true); + // Force to render the message + fakeElementById.querySelector.returns(null); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns([messages[0]]); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledOnce(fakeSendTelemetry); + + spy.resetHistory(); + + // Message click event listener cb + eventListeners.mouseup(); + + assert.calledOnce(spy); + assert.calledWithExactly(spy, fakeWindow, "CLICK", messages[0]); + }); + it("should dispatch a IMPRESSION with toolbar_dropdown", async () => { + // means panel is triggered from the toolbar button + fakeElementById.hasAttribute.returns(true); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.resolves(messages); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + const panelPingId = messages + .map(({ id }) => id) + .sort() + .join(","); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledWithExactly( + spy, + fakeWindow, + "IMPRESSION", + { + id: panelPingId, + }, + { + value: { + view: "toolbar_dropdown", + }, + } + ); + assert.calledOnce(fakeSendTelemetry); + const { + args: [dispatchPayload], + } = fakeSendTelemetry.lastCall; + assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY"); + assert.propertyVal(dispatchPayload.data, "message_id", panelPingId); + assert.deepEqual(dispatchPayload.data.event_context, { + view: "toolbar_dropdown", + }); + }); + it("should dispatch a IMPRESSION with application_menu", async () => { + // means panel is triggered as a subview in the application menu + fakeElementById.hasAttribute.returns(false); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.resolves(messages); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + const panelPingId = messages + .map(({ id }) => id) + .sort() + .join(","); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledWithExactly( + spy, + fakeWindow, + "IMPRESSION", + { + id: panelPingId, + }, + { + value: { + view: "application_menu", + }, + } + ); + assert.calledOnce(fakeSendTelemetry); + const { + args: [dispatchPayload], + } = fakeSendTelemetry.lastCall; + assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY"); + assert.propertyVal(dispatchPayload.data, "message_id", panelPingId); + assert.deepEqual(dispatchPayload.data.event_context, { + view: "application_menu", + }); + }); + }); + describe("#forceShowMessage", () => { + const panelSelector = "PanelUI-whatsNew-message-container"; + let removeMessagesSpy; + let renderMessagesStub; + let addEventListenerStub; + let messages; + let browser; + beforeEach(async () => { + messages = (await PanelTestProvider.getMessages()).find( + m => m.id === "WHATS_NEW_70_1" + ); + removeMessagesSpy = sandbox.spy(instance, "removeMessages"); + renderMessagesStub = sandbox.spy(instance, "renderMessages"); + addEventListenerStub = fakeElementById.addEventListener; + browser = { ownerGlobal: fakeWindow, ownerDocument: fakeDocument }; + fakeElementById.querySelectorAll.returns([fakeElementById]); + }); + it("should call removeMessages when forcing a message to show", () => { + instance.forceShowMessage(browser, messages); + + assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector); + }); + it("should call renderMessages when forcing a message to show", () => { + instance.forceShowMessage(browser, messages); + + assert.calledOnce(renderMessagesStub); + assert.calledWithExactly( + renderMessagesStub, + fakeWindow, + fakeDocument, + panelSelector, + { + force: true, + messages: Array.isArray(messages) ? messages : [messages], + } + ); + }); + it("should cleanup after the panel is hidden when forcing a message to show", () => { + instance.forceShowMessage(browser, messages); + + assert.calledOnce(addEventListenerStub); + assert.calledWithExactly( + addEventListenerStub, + "popuphidden", + sinon.match.func + ); + + const [, cb] = addEventListenerStub.firstCall.args; + // Reset the call count from the first `forceShowMessage` call + removeMessagesSpy.resetHistory(); + cb({ target: { ownerGlobal: fakeWindow } }); + + assert.calledOnce(removeMessagesSpy); + assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector); + }); + it("should exit gracefully if called before a browser exists", () => { + instance.forceShowMessage(null, messages); + assert.neverCalledWith(removeMessagesSpy, fakeWindow, panelSelector); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/asrouter-utils.test.js b/browser/components/asrouter/tests/unit/asrouter-utils.test.js new file mode 100644 index 0000000000..553c9608ed --- /dev/null +++ b/browser/components/asrouter/tests/unit/asrouter-utils.test.js @@ -0,0 +1,118 @@ +import { ASRouterUtils } from "content-src/asrouter-utils"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("ASRouterUtils", () => { + let globals = null; + let overrider = null; + let sandbox = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + globals = { + ASRouterMessage: sandbox.stub().resolves({}), + }; + overrider = new GlobalOverrider(); + overrider.set(globals); + }); + afterEach(() => { + sandbox.restore(); + overrider.restore(); + }); + describe("sendMessage", () => { + it("default", () => { + ASRouterUtils.sendMessage({ foo: "bar" }); + assert.calledOnce(globals.ASRouterMessage); + assert.calledWith(globals.ASRouterMessage, { foo: "bar" }); + }); + it("throws if ASRouterMessage is not defined", () => { + overrider.set("ASRouterMessage", undefined); + assert.throws(() => ASRouterUtils.sendMessage({ foo: "bar" })); + }); + it("can accept the legacy NEWTAB_MESSAGE_REQUEST message without throwing", async () => { + assert.doesNotThrow(async () => { + let result = await ASRouterUtils.sendMessage({ + type: "NEWTAB_MESSAGE_REQUEST", + data: {}, + }); + sandbox.assert.deepEqual(result, {}); + }); + }); + }); + describe("blockById", () => { + it("default", () => { + ASRouterUtils.blockById(1, { foo: "bar" }); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { foo: "bar", id: 1 } }) + ); + }); + }); + describe("modifyMessageJson", () => { + it("default", () => { + ASRouterUtils.modifyMessageJson({ foo: "bar" }); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { content: { foo: "bar" } } }) + ); + }); + }); + describe("executeAction", () => { + it("default", () => { + ASRouterUtils.executeAction({ foo: "bar" }); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { foo: "bar" } }) + ); + }); + }); + describe("unblockById", () => { + it("default", () => { + ASRouterUtils.unblockById(2); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { id: 2 } }) + ); + }); + }); + describe("blockBundle", () => { + it("default", () => { + ASRouterUtils.blockBundle(2); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { bundle: 2 } }) + ); + }); + }); + describe("unblockBundle", () => { + it("default", () => { + ASRouterUtils.unblockBundle(2); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { bundle: 2 } }) + ); + }); + }); + describe("overrideMessage", () => { + it("default", () => { + ASRouterUtils.overrideMessage(12); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { id: 12 } }) + ); + }); + }); + describe("editState", () => { + it("default", () => { + ASRouterUtils.editState("foo", "bar"); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { foo: "bar" } }) + ); + }); + }); + describe("sendTelemetry", () => { + it("default", () => { + ASRouterUtils.sendTelemetry({ foo: "bar" }); + assert.calledOnce(globals.ASRouterMessage); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/constants.js b/browser/components/asrouter/tests/unit/constants.js new file mode 100644 index 0000000000..82b88c47a2 --- /dev/null +++ b/browser/components/asrouter/tests/unit/constants.js @@ -0,0 +1,131 @@ +export const CHILD_TO_PARENT_MESSAGE_NAME = "ASRouter:child-to-parent"; +export const PARENT_TO_CHILD_MESSAGE_NAME = "ASRouter:parent-to-child"; + +export const FAKE_LOCAL_MESSAGES = [ + { + id: "foo", + template: "milestone_message", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "foo1", + template: "fancy_template", + bundled: 2, + order: 1, + content: { title: "Foo1", body: "Foo123-1" }, + }, + { + id: "foo2", + template: "fancy_template", + bundled: 2, + order: 2, + content: { title: "Foo2", body: "Foo123-2" }, + }, + { + id: "bar", + template: "fancy_template", + content: { title: "Foo", body: "Foo123" }, + }, + { id: "baz", content: { title: "Foo", body: "Foo123" } }, + { + id: "newsletter", + template: "fancy_template", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "fxa", + template: "fancy_template", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "belowsearch", + template: "fancy_template", + content: { text: "Foo" }, + }, +]; +export const FAKE_LOCAL_PROVIDER = { + id: "onboarding", + type: "local", + localProvider: "FAKE_LOCAL_PROVIDER", + enabled: true, + cohort: 0, +}; +export const FAKE_LOCAL_PROVIDERS = { + FAKE_LOCAL_PROVIDER: { + getMessages: () => Promise.resolve(FAKE_LOCAL_MESSAGES), + }, +}; + +export const FAKE_REMOTE_MESSAGES = [ + { + id: "qux", + template: "fancy_template", + content: { title: "Qux", body: "hello world" }, + }, +]; +export const FAKE_REMOTE_PROVIDER = { + id: "remotey", + type: "remote", + url: "http://fake.com/endpoint", + enabled: true, +}; + +export const FAKE_REMOTE_SETTINGS_PROVIDER = { + id: "remotey-settingsy", + type: "remote-settings", + collection: "collectionname", + enabled: true, +}; + +const notificationText = new String("Fake notification text"); // eslint-disable-line +notificationText.attributes = { tooltiptext: "Fake tooltip text" }; + +export const FAKE_RECOMMENDATION = { + id: "fake_id", + template: "cfr_doorhanger", + content: { + category: "cfrDummy", + bucket_id: "fake_bucket_id", + notification_text: notificationText, + info_icon: { + label: "Fake Info Icon Label", + sumo_path: "a_help_path_fragment", + }, + heading_text: "Fake Heading Text", + icon_class: "Fake Icon class", + addon: { + title: "Fake Addon Title", + author: "Fake Addon Author", + icon: "a_path_to_some_icon", + rating: "4.2", + users: "1234", + amo_url: "a_path_to_amo", + }, + descriptionDetails: { + steps: [{ string_id: "cfr-features-step1" }], + }, + text: "Here is the recommendation text body", + buttons: { + primary: { + label: { string_id: "primary_button_id" }, + action: { + id: "primary_action", + data: {}, + }, + }, + secondary: [ + { + label: { string_id: "secondary_button_id" }, + action: { id: "secondary_action" }, + }, + { + label: { string_id: "secondary_button_id_2" }, + }, + { + label: { string_id: "secondary_button_id_3" }, + action: { id: "secondary_action" }, + }, + ], + }, + }, +}; diff --git a/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx b/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx new file mode 100644 index 0000000000..46d5704107 --- /dev/null +++ b/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx @@ -0,0 +1,262 @@ +import { ASRouterAdminInner } from "content-src/components/ASRouterAdmin/ASRouterAdmin"; +import { ASRouterUtils } from "content-src/asrouter-utils"; +import { GlobalOverrider } from "test/unit/utils"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("ASRouterAdmin", () => { + let globalOverrider; + let sandbox; + let wrapper; + let globals; + let FAKE_PROVIDER_PREF = [ + { + enabled: true, + id: "local_testing", + localProvider: "TestProvider", + type: "local", + }, + ]; + let FAKE_PROVIDER = [ + { + enabled: true, + id: "local_testing", + localProvider: "TestProvider", + messages: [], + type: "local", + }, + ]; + beforeEach(() => { + globalOverrider = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + sandbox.stub(ASRouterUtils, "getPreviewEndpoint").returns("foo"); + globals = { + ASRouterMessage: sandbox.stub().resolves(), + ASRouterAddParentListener: sandbox.stub(), + ASRouterRemoveParentListener: sandbox.stub(), + }; + globalOverrider.set(globals); + wrapper = shallow(<ASRouterAdminInner location={{ routes: [""] }} />); + wrapper.setState({ devtoolsEnabled: true }); + }); + afterEach(() => { + sandbox.restore(); + globalOverrider.restore(); + }); + it("should render ASRouterAdmin component", () => { + assert.ok(wrapper.exists()); + }); + it("should send ADMIN_CONNECT_STATE on mount", () => { + assert.calledOnce(globals.ASRouterMessage); + assert.calledWith(globals.ASRouterMessage, { + type: "ADMIN_CONNECT_STATE", + data: { endpoint: "foo" }, + }); + }); + describe("#getSection", () => { + it("should render a message provider section by default", () => { + assert.equal(wrapper.find("h2").at(1).text(), "Messages"); + }); + it("should render a targeting section for targeting route", () => { + wrapper = shallow( + <ASRouterAdminInner location={{ routes: ["targeting"] }} /> + ); + wrapper.setState({ devtoolsEnabled: true }); + assert.equal(wrapper.find("h2").at(0).text(), "Targeting Utilities"); + }); + it("should render two error messages", () => { + wrapper = shallow( + <ASRouterAdminInner location={{ routes: ["errors"] }} Sections={[]} /> + ); + wrapper.setState({ devtoolsEnabled: true }); + const firstError = { + timestamp: Date.now() + 100, + error: { message: "first" }, + }; + const secondError = { + timestamp: Date.now(), + error: { message: "second" }, + }; + wrapper.setState({ + providers: [{ id: "foo", errors: [firstError, secondError] }], + }); + + assert.equal( + wrapper.find("tbody tr").at(0).find("td").at(0).text(), + "foo" + ); + assert.lengthOf(wrapper.find("tbody tr"), 2); + assert.equal( + wrapper.find("tbody tr").at(0).find("td").at(1).text(), + secondError.error.message + ); + }); + }); + describe("#render", () => { + beforeEach(() => { + wrapper.setState({ + providerPrefs: [], + providers: [], + userPrefs: {}, + }); + }); + describe("#renderProviders", () => { + it("should render the provider", () => { + wrapper.setState({ + providerPrefs: FAKE_PROVIDER_PREF, + providers: FAKE_PROVIDER, + }); + + // Header + 1 item + assert.lengthOf(wrapper.find(".message-item"), 2); + }); + }); + describe("#renderMessages", () => { + beforeEach(() => { + sandbox.stub(ASRouterUtils, "blockById").resolves(); + sandbox.stub(ASRouterUtils, "unblockById").resolves(); + sandbox.stub(ASRouterUtils, "overrideMessage").resolves({ foo: "bar" }); + sandbox.stub(ASRouterUtils, "sendMessage").resolves(); + wrapper.setState({ + messageFilter: "all", + messageBlockList: [], + messageImpressions: { foo: 2 }, + groups: [{ id: "messageProvider", enabled: true }], + providers: [{ id: "messageProvider", enabled: true }], + }); + }); + it("should render a message when no filtering is applied", () => { + wrapper.setState({ + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + wrapper.find(".message-item button.primary").simulate("click"); + assert.calledOnce(ASRouterUtils.blockById); + assert.calledWith(ASRouterUtils.blockById, "foo"); + }); + it("should render a blocked message", () => { + wrapper.setState({ + messages: [ + { + id: "foo", + groups: ["messageProvider"], + provider: "messageProvider", + }, + ], + messageBlockList: ["foo"], + }); + assert.lengthOf(wrapper.find(".message-item.blocked"), 1); + wrapper.find(".message-item.blocked button").simulate("click"); + assert.calledOnce(ASRouterUtils.unblockById); + assert.calledWith(ASRouterUtils.unblockById, "foo"); + }); + it("should render a message if provider matches filter", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + }); + it("should override with the selected message", async () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + wrapper.find(".message-item button.show").simulate("click"); + assert.calledOnce(ASRouterUtils.overrideMessage); + assert.calledWith(ASRouterUtils.overrideMessage, "foo"); + await ASRouterUtils.overrideMessage(); + assert.equal(wrapper.state().foo, "bar"); + }); + it("should hide message if provider filter changes", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + + wrapper.find("select").simulate("change", { target: { value: "bar" } }); + + assert.lengthOf(wrapper.find(".message-id"), 0); + }); + it("should not display Reset All button if provider filter value is set to all or test providers", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".messages-reset"), 1); + wrapper.find("select").simulate("change", { target: { value: "all" } }); + + assert.lengthOf(wrapper.find(".messages-reset"), 0); + + wrapper + .find("select") + .simulate("change", { target: { value: "test_local_testing" } }); + assert.lengthOf(wrapper.find(".messages-reset"), 0); + }); + it("should trigger disable and enable provider on Reset All button click", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + providerPrefs: [ + { + id: "messageProvider", + }, + ], + }); + wrapper.find(".messages-reset").simulate("click"); + assert.calledTwice(ASRouterUtils.sendMessage); + assert.calledWith(ASRouterUtils.sendMessage, { + type: "DISABLE_PROVIDER", + data: "messageProvider", + }); + assert.calledWith(ASRouterUtils.sendMessage, { + type: "ENABLE_PROVIDER", + data: "messageProvider", + }); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/templates/ExtensionDoorhanger.test.jsx b/browser/components/asrouter/tests/unit/templates/ExtensionDoorhanger.test.jsx new file mode 100644 index 0000000000..9d9984ee85 --- /dev/null +++ b/browser/components/asrouter/tests/unit/templates/ExtensionDoorhanger.test.jsx @@ -0,0 +1,112 @@ +import { CFRMessageProvider } from "modules/CFRMessageProvider.sys.mjs"; +import CFRDoorhangerSchema from "content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json"; +import CFRChicletSchema from "content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json"; +import InfoBarSchema from "content-src/templates/CFR/templates/InfoBar.schema.json"; + +const SCHEMAS = { + cfr_urlbar_chiclet: CFRChicletSchema, + cfr_doorhanger: CFRDoorhangerSchema, + milestone_message: CFRDoorhangerSchema, + infobar: InfoBarSchema, +}; + +const DEFAULT_CONTENT = { + layout: "addon_recommendation", + category: "dummyCategory", + bucket_id: "some_bucket_id", + notification_text: "Recommendation", + heading_text: "Recommended Extension", + info_icon: { + label: { attributes: { tooltiptext: "Why am I seeing this" } }, + sumo_path: "extensionrecommendations", + }, + addon: { + id: "1234", + title: "Addon name", + icon: "https://mozilla.org/icon", + author: "Author name", + amo_url: "https://example.com", + }, + text: "Description of addon", + buttons: { + primary: { + label: { + value: "Add Now", + attributes: { accesskey: "A" }, + }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com" }, + }, + }, + secondary: [ + { + label: { + value: "Not Now", + attributes: { accesskey: "N" }, + }, + action: { type: "CANCEL" }, + }, + ], + }, +}; + +const L10N_CONTENT = { + layout: "addon_recommendation", + category: "dummyL10NCategory", + bucket_id: "some_bucket_id", + notification_text: { string_id: "notification_text_id" }, + heading_text: { string_id: "heading_text_id" }, + info_icon: { + label: { string_id: "why_seeing_this" }, + sumo_path: "extensionrecommendations", + }, + addon: { + id: "1234", + title: "Addon name", + icon: "https://mozilla.org/icon", + author: "Author name", + amo_url: "https://example.com", + }, + text: { string_id: "text_id" }, + buttons: { + primary: { + label: { string_id: "btn_ok_id" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com" }, + }, + }, + secondary: [ + { + label: { string_id: "btn_cancel_id" }, + action: { type: "CANCEL" }, + }, + ], + }, +}; + +describe("ExtensionDoorhanger", () => { + it("should validate DEFAULT_CONTENT", async () => { + const messages = await CFRMessageProvider.getMessages(); + let doorhangerMessage = messages.find(m => m.id === "FACEBOOK_CONTAINER_3"); + assert.ok(doorhangerMessage, "Message found"); + assert.jsonSchema( + { ...doorhangerMessage, content: DEFAULT_CONTENT }, + CFRDoorhangerSchema + ); + }); + it("should validate L10N_CONTENT", async () => { + const messages = await CFRMessageProvider.getMessages(); + let doorhangerMessage = messages.find(m => m.id === "FACEBOOK_CONTAINER_3"); + assert.ok(doorhangerMessage, "Message found"); + assert.jsonSchema( + { ...doorhangerMessage, content: L10N_CONTENT }, + CFRDoorhangerSchema + ); + }); + it("should validate all messages from CFRMessageProvider", async () => { + const messages = await CFRMessageProvider.getMessages(); + messages.forEach(msg => assert.jsonSchema(msg, SCHEMAS[msg.template])); + }); +}); diff --git a/browser/components/asrouter/tests/unit/unit-entry.js b/browser/components/asrouter/tests/unit/unit-entry.js new file mode 100644 index 0000000000..b8b799e051 --- /dev/null +++ b/browser/components/asrouter/tests/unit/unit-entry.js @@ -0,0 +1,727 @@ +import { + EventEmitter, + FakePrefs, + FakensIPrefService, + GlobalOverrider, + FakeConsoleAPI, + FakeLogger, +} from "newtab/test/unit/utils"; +import Adapter from "enzyme-adapter-react-16"; +import { chaiAssertions } from "newtab/test/schemas/pings"; +import chaiJsonSchema from "chai-json-schema"; +import enzyme from "enzyme"; +import FxMSCommonSchema from "../../content-src/schemas/FxMSCommon.schema.json"; +import { + MESSAGE_TYPE_LIST, + MESSAGE_TYPE_HASH, +} from "modules/ActorConstants.sys.mjs"; + +enzyme.configure({ adapter: new Adapter() }); + +// Cause React warnings to make tests that trigger them fail +const origConsoleError = console.error; +console.error = function (msg, ...args) { + origConsoleError.apply(console, [msg, ...args]); + + if ( + /(Invalid prop|Failed prop type|Check the render method|React Intl)/.test( + msg + ) + ) { + throw new Error(msg); + } +}; + +const req = require.context(".", true, /\.test\.jsx?$/); +const files = req.keys(); + +// This exposes sinon assertions to chai.assert +sinon.assert.expose(assert, { prefix: "" }); + +chai.use(chaiAssertions); +chai.use(chaiJsonSchema); +chai.tv4.addSchema("file:///FxMSCommon.schema.json", FxMSCommonSchema); + +const overrider = new GlobalOverrider(); + +const RemoteSettings = name => ({ + get: () => { + if (name === "attachment") { + return Promise.resolve([{ attachment: {} }]); + } + return Promise.resolve([]); + }, + on: () => {}, + off: () => {}, +}); +RemoteSettings.pollChanges = () => {}; + +class JSWindowActorParent { + sendAsyncMessage(name, data) { + return { name, data }; + } +} + +class JSWindowActorChild { + sendAsyncMessage(name, data) { + return { name, data }; + } + + sendQuery(name, data) { + return Promise.resolve({ name, data }); + } + + get contentWindow() { + return { + Promise, + }; + } +} + +// Detect plain object passed to lazy getter APIs, and set its prototype to +// global object, and return the global object for further modification. +// Returns the object if it's not plain object. +// +// This is a workaround to make the existing testharness and testcase keep +// working even after lazy getters are moved to plain `lazy` object. +const cachedPlainObject = new Set(); +function updateGlobalOrObject(object) { + // Given this function modifies the prototype, and the following + // condition doesn't meet on the second call, cache the result. + if (cachedPlainObject.has(object)) { + return global; + } + + if (Object.getPrototypeOf(object).constructor.name !== "Object") { + return object; + } + + cachedPlainObject.add(object); + Object.setPrototypeOf(object, global); + return global; +} + +const TEST_GLOBAL = { + JSWindowActorParent, + JSWindowActorChild, + AboutReaderParent: { + addMessageListener: (messageName, listener) => {}, + removeMessageListener: (messageName, listener) => {}, + }, + AboutWelcomeTelemetry: class { + submitGleanPingForPing() {} + }, + AddonManager: { + getActiveAddons() { + return Promise.resolve({ addons: [], fullData: false }); + }, + }, + AppConstants: { + MOZILLA_OFFICIAL: true, + MOZ_APP_VERSION: "69.0a1", + isChinaRepack() { + return false; + }, + isPlatformAndVersionAtMost() { + return false; + }, + platform: "win", + }, + ASRouterPreferences: { + console: new FakeConsoleAPI({ + maxLogLevel: "off", // set this to "debug" or "all" to get more ASRouter logging in tests + prefix: "ASRouter", + }), + }, + AWScreenUtils: { + evaluateTargetingAndRemoveScreens() { + return true; + }, + async removeScreens() { + return true; + }, + evaluateScreenTargeting() { + return true; + }, + }, + BrowserUtils: { + sendToDeviceEmailsSupported() { + return true; + }, + }, + UpdateUtils: { getUpdateChannel() {} }, + BasePromiseWorker: class { + constructor() { + this.ExceptionHandlers = []; + } + post() {} + }, + browserSearchRegion: "US", + BrowserWindowTracker: { getTopWindow() {} }, + ChromeUtils: { + defineLazyGetter(object, name, f) { + updateGlobalOrObject(object)[name] = f(); + }, + defineModuleGetter: updateGlobalOrObject, + defineESModuleGetters: updateGlobalOrObject, + generateQI() { + return {}; + }, + import() { + return global; + }, + importESModule() { + return global; + }, + }, + ClientEnvironment: { + get userId() { + return "foo123"; + }, + }, + Components: { + Constructor(classId) { + switch (classId) { + case "@mozilla.org/referrer-info;1": + return function (referrerPolicy, sendReferrer, originalReferrer) { + this.referrerPolicy = referrerPolicy; + this.sendReferrer = sendReferrer; + this.originalReferrer = originalReferrer; + }; + } + return function () {}; + }, + isSuccessCode: () => true, + }, + ConsoleAPI: FakeConsoleAPI, + // NB: These are functions/constructors + // eslint-disable-next-line object-shorthand + ContentSearchUIController: function () {}, + // eslint-disable-next-line object-shorthand + ContentSearchHandoffUIController: function () {}, + Cc: { + "@mozilla.org/browser/nav-bookmarks-service;1": { + addObserver() {}, + getService() { + return this; + }, + removeObserver() {}, + SOURCES: {}, + TYPE_BOOKMARK: {}, + }, + "@mozilla.org/browser/nav-history-service;1": { + addObserver() {}, + executeQuery() {}, + getNewQuery() {}, + getNewQueryOptions() {}, + getService() { + return this; + }, + insert() {}, + markPageAsTyped() {}, + removeObserver() {}, + }, + "@mozilla.org/io/string-input-stream;1": { + createInstance() { + return {}; + }, + }, + "@mozilla.org/security/hash;1": { + createInstance() { + return { + init() {}, + updateFromStream() {}, + finish() { + return "0"; + }, + }; + }, + }, + "@mozilla.org/updates/update-checker;1": { createInstance() {} }, + "@mozilla.org/widget/useridleservice;1": { + getService() { + return { + idleTime: 0, + addIdleObserver() {}, + removeIdleObserver() {}, + }; + }, + }, + "@mozilla.org/streamConverters;1": { + getService() { + return this; + }, + }, + "@mozilla.org/network/stream-loader;1": { + createInstance() { + return {}; + }, + }, + }, + Ci: { + nsICryptoHash: {}, + nsIReferrerInfo: { UNSAFE_URL: 5 }, + nsITimer: { TYPE_ONE_SHOT: 1 }, + nsIWebProgressListener: { LOCATION_CHANGE_SAME_DOCUMENT: 1 }, + nsIDOMWindow: Object, + nsITrackingDBService: { + TRACKERS_ID: 1, + TRACKING_COOKIES_ID: 2, + CRYPTOMINERS_ID: 3, + FINGERPRINTERS_ID: 4, + SOCIAL_ID: 5, + }, + nsICookieBannerService: { + MODE_DISABLED: 0, + MODE_REJECT: 1, + MODE_REJECT_OR_ACCEPT: 2, + MODE_UNSET: 3, + }, + }, + Cu: { + importGlobalProperties() {}, + now: () => window.performance.now(), + cloneInto: o => JSON.parse(JSON.stringify(o)), + }, + console: { + ...console, + error() {}, + }, + dump() {}, + EveryWindow: { + registerCallback: (id, init, uninit) => {}, + unregisterCallback: id => {}, + }, + setTimeout: window.setTimeout.bind(window), + clearTimeout: window.clearTimeout.bind(window), + fetch() {}, + // eslint-disable-next-line object-shorthand + Image: function () {}, // NB: This is a function/constructor + IOUtils: { + writeJSON() { + return Promise.resolve(0); + }, + readJSON() { + return Promise.resolve({}); + }, + read() { + return Promise.resolve(new Uint8Array()); + }, + makeDirectory() { + return Promise.resolve(0); + }, + write() { + return Promise.resolve(0); + }, + exists() { + return Promise.resolve(0); + }, + remove() { + return Promise.resolve(0); + }, + stat() { + return Promise.resolve(0); + }, + }, + NewTabUtils: { + activityStreamProvider: { + getTopFrecentSites: () => [], + executePlacesQuery: async (sql, options) => ({ sql, options }), + }, + }, + OS: { + File: { + writeAtomic() {}, + makeDir() {}, + stat() {}, + Error: {}, + read() {}, + exists() {}, + remove() {}, + removeEmptyDir() {}, + }, + Path: { + join() { + return "/"; + }, + }, + Constants: { + Path: { + localProfileDir: "/", + }, + }, + }, + PathUtils: { + join(...parts) { + return parts[parts.length - 1]; + }, + joinRelative(...parts) { + return parts[parts.length - 1]; + }, + getProfileDir() { + return Promise.resolve("/"); + }, + getLocalProfileDir() { + return Promise.resolve("/"); + }, + }, + PlacesUtils: { + get bookmarks() { + return TEST_GLOBAL.Cc["@mozilla.org/browser/nav-bookmarks-service;1"]; + }, + get history() { + return TEST_GLOBAL.Cc["@mozilla.org/browser/nav-history-service;1"]; + }, + observers: { + addListener() {}, + removeListener() {}, + }, + }, + Preferences: FakePrefs, + PrivateBrowsingUtils: { + isBrowserPrivate: () => false, + isWindowPrivate: () => false, + permanentPrivateBrowsing: false, + }, + DownloadsViewUI: { + getDisplayName: () => "filename.ext", + getSizeWithUnits: () => "1.5 MB", + }, + FileUtils: { + // eslint-disable-next-line object-shorthand + File: function () {}, // NB: This is a function/constructor + }, + Region: { + home: "US", + REGION_TOPIC: "browser-region-updated", + }, + Services: { + dirsvc: { + get: () => ({ parent: { parent: { path: "appPath" } } }), + }, + env: { + set: () => undefined, + }, + locale: { + get appLocaleAsBCP47() { + return "en-US"; + }, + negotiateLanguages() {}, + }, + urlFormatter: { formatURL: str => str, formatURLPref: str => str }, + mm: { + addMessageListener: (msg, cb) => this.receiveMessage(), + removeMessageListener() {}, + }, + obs: { + addObserver() {}, + removeObserver() {}, + notifyObservers() {}, + }, + telemetry: { + setEventRecordingEnabled: () => {}, + recordEvent: eventDetails => {}, + scalarSet: () => {}, + keyedScalarAdd: () => {}, + }, + uuid: { + generateUUID() { + return "{foo-123-foo}"; + }, + }, + console: { logStringMessage: () => {} }, + prefs: new FakensIPrefService(), + tm: { + dispatchToMainThread: cb => cb(), + idleDispatchToMainThread: cb => cb(), + }, + eTLD: { + getBaseDomain({ spec }) { + return spec.match(/\/([^/]+)/)[1]; + }, + getBaseDomainFromHost(host) { + return host.match(/.*?(\w+\.\w+)$/)[1]; + }, + getPublicSuffix() {}, + }, + io: { + newURI: spec => ({ + mutate: () => ({ + setRef: ref => ({ + finalize: () => ({ + ref, + spec, + }), + }), + }), + spec, + }), + }, + search: { + init() { + return Promise.resolve(); + }, + getVisibleEngines: () => + Promise.resolve([{ identifier: "google" }, { identifier: "bing" }]), + defaultEngine: { + identifier: "google", + searchForm: + "https://www.google.com/search?q=&ie=utf-8&oe=utf-8&client=firefox-b", + aliases: ["@google"], + }, + defaultPrivateEngine: { + identifier: "bing", + searchForm: "https://www.bing.com", + aliases: ["@bing"], + }, + getEngineByAlias: async () => null, + }, + scriptSecurityManager: { + createNullPrincipal() {}, + getSystemPrincipal() {}, + }, + wm: { + getMostRecentWindow: () => window, + getMostRecentBrowserWindow: () => window, + getEnumerator: () => [], + }, + ww: { registerNotification() {}, unregisterNotification() {} }, + appinfo: { appBuildID: "20180710100040", version: "69.0a1" }, + scriptloader: { loadSubScript: () => {} }, + startup: { + getStartupInfo() { + return { + process: { + getTime() { + return 1588010448000; + }, + }, + }; + }, + }, + }, + XPCOMUtils: { + defineLazyGlobalGetters: updateGlobalOrObject, + defineLazyModuleGetters: updateGlobalOrObject, + defineLazyServiceGetter: updateGlobalOrObject, + defineLazyServiceGetters: updateGlobalOrObject, + defineLazyPreferenceGetter(object, name) { + updateGlobalOrObject(object)[name] = ""; + }, + generateQI() { + return {}; + }, + }, + EventEmitter, + ShellService: { + doesAppNeedPin: () => false, + isDefaultBrowser: () => true, + }, + FilterExpressions: { + eval() { + return Promise.resolve(false); + }, + }, + RemoteSettings, + Localization: class { + async formatMessages(stringsIds) { + return Promise.resolve( + stringsIds.map(({ id, args }) => ({ value: { string_id: id, args } })) + ); + } + async formatValue(stringId) { + return Promise.resolve(stringId); + } + }, + FxAccountsConfig: { + promiseConnectAccountURI(id) { + return Promise.resolve(id); + }, + }, + FX_MONITOR_OAUTH_CLIENT_ID: "fake_client_id", + ExperimentAPI: { + getExperiment() {}, + getExperimentMetaData() {}, + getRolloutMetaData() {}, + }, + NimbusFeatures: { + glean: { + getVariable() {}, + }, + newtab: { + getVariable() {}, + getAllVariables() {}, + onUpdate() {}, + offUpdate() {}, + }, + pocketNewtab: { + getVariable() {}, + getAllVariables() {}, + onUpdate() {}, + offUpdate() {}, + }, + cookieBannerHandling: { + getVariable() {}, + }, + }, + TelemetryEnvironment: { + setExperimentActive() {}, + currentEnvironment: { + profile: { + creationDate: 16587, + }, + settings: {}, + }, + }, + TelemetryStopwatch: { + start: () => {}, + finish: () => {}, + }, + Sampling: { + ratioSample(seed, ratios) { + return Promise.resolve(0); + }, + }, + BrowserHandler: { + get kiosk() { + return false; + }, + }, + TelemetrySession: { + getMetadata(reason) { + return { + reason, + sessionId: "fake_session_id", + }; + }, + }, + PageThumbs: { + addExpirationFilter() {}, + removeExpirationFilter() {}, + }, + Logger: FakeLogger, + getFxAccountsSingleton() {}, + AboutNewTab: {}, + Glean: { + newtab: { + opened: { + record() {}, + }, + closed: { + record() {}, + }, + locale: { + set() {}, + }, + newtabCategory: { + set() {}, + }, + homepageCategory: { + set() {}, + }, + blockedSponsors: { + set() {}, + }, + sovAllocation: { + set() {}, + }, + }, + newtabSearch: { + enabled: { + set() {}, + }, + }, + pocket: { + enabled: { + set() {}, + }, + impression: { + record() {}, + }, + isSignedIn: { + set() {}, + }, + sponsoredStoriesEnabled: { + set() {}, + }, + click: { + record() {}, + }, + save: { + record() {}, + }, + topicClick: { + record() {}, + }, + }, + topsites: { + enabled: { + set() {}, + }, + sponsoredEnabled: { + set() {}, + }, + impression: { + record() {}, + }, + click: { + record() {}, + }, + rows: { + set() {}, + }, + showPrivacyClick: { + record() {}, + }, + dismiss: { + record() {}, + }, + prefChanged: { + record() {}, + }, + }, + topSites: { + pingType: { + set() {}, + }, + position: { + set() {}, + }, + source: { + set() {}, + }, + tileId: { + set() {}, + }, + reportingUrl: { + set() {}, + }, + advertiser: { + set() {}, + }, + contextId: { + set() {}, + }, + }, + }, + GleanPings: { + newtab: { + submit() {}, + }, + topSites: { + submit() {}, + }, + }, + Utils: { + SERVER_URL: "bogus://foo", + }, + + MESSAGE_TYPE_LIST, + MESSAGE_TYPE_HASH, +}; +overrider.set(TEST_GLOBAL); + +describe("asrouter", () => { + after(() => overrider.restore()); + files.forEach(file => req(file)); +}); |