diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/newtab/test/unit/asrouter | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/newtab/test/unit/asrouter')
31 files changed, 10406 insertions, 0 deletions
diff --git a/browser/components/newtab/test/unit/asrouter/ASRouter.test.js b/browser/components/newtab/test/unit/asrouter/ASRouter.test.js new file mode 100644 index 0000000000..732200b408 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouter.test.js @@ -0,0 +1,3040 @@ +import { _ASRouter, MessageLoaderUtils } from "lib/ASRouter.jsm"; +import { QueryCache } from "lib/ASRouterTargeting.jsm"; +import { + FAKE_LOCAL_MESSAGES, + FAKE_LOCAL_PROVIDER, + FAKE_LOCAL_PROVIDERS, + FAKE_REMOTE_MESSAGES, + FAKE_REMOTE_PROVIDER, + FAKE_REMOTE_SETTINGS_PROVIDER, +} from "./constants"; +import { + ASRouterPreferences, + TARGETING_PREFERENCES, +} from "lib/ASRouterPreferences.jsm"; +import { ASRouterTriggerListeners } from "lib/ASRouterTriggerListeners.jsm"; +import { CFRPageActions } from "lib/CFRPageActions.jsm"; +import { GlobalOverrider } from "test/unit/utils"; +import { PanelTestProvider } from "lib/PanelTestProvider.sys.mjs"; +import ProviderResponseSchema from "content-src/asrouter/schemas/provider-response.schema.json"; +import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.sys.mjs"; + +const MESSAGE_PROVIDER_PREF_NAME = + "browser.newtabpage.activity-stream.asrouter.providers.snippets"; +const FAKE_PROVIDERS = [ + FAKE_LOCAL_PROVIDER, + FAKE_REMOTE_PROVIDER, + FAKE_REMOTE_SETTINGS_PROVIDER, +]; +const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; +const FAKE_RESPONSE_HEADERS = { get() {} }; +const FAKE_BUNDLE = [FAKE_LOCAL_MESSAGES[1], FAKE_LOCAL_MESSAGES[2]]; + +const USE_REMOTE_L10N_PREF = + "browser.newtabpage.activity-stream.asrouter.useRemoteL10n"; + +// eslint-disable-next-line max-statements +describe("ASRouter", () => { + let Router; + let globals; + let sandbox; + let initParams; + let messageBlockList; + let providerBlockList; + let messageImpressions; + let groupImpressions; + let previousSessionEnd; + let fetchStub; + let clock; + let getStringPrefStub; + let fakeAttributionCode; + let fakeTargetingContext; + let FakeToolbarBadgeHub; + let FakeToolbarPanelHub; + let FakeMomentsPageHub; + let ASRouterTargeting; + let screenImpressions; + + function setMessageProviderPref(value) { + sandbox.stub(ASRouterPreferences, "providers").get(() => value); + } + + function initASRouter(router) { + const getStub = sandbox.stub(); + getStub.returns(Promise.resolve()); + getStub + .withArgs("messageBlockList") + .returns(Promise.resolve(messageBlockList)); + getStub + .withArgs("providerBlockList") + .returns(Promise.resolve(providerBlockList)); + getStub + .withArgs("messageImpressions") + .returns(Promise.resolve(messageImpressions)); + getStub.withArgs("groupImpressions").resolves(groupImpressions); + getStub + .withArgs("previousSessionEnd") + .returns(Promise.resolve(previousSessionEnd)); + getStub + .withArgs("screenImpressions") + .returns(Promise.resolve(screenImpressions)); + initParams = { + storage: { + get: getStub, + set: sandbox.stub().returns(Promise.resolve()), + }, + sendTelemetry: sandbox.stub().resolves(), + clearChildMessages: sandbox.stub().resolves(), + clearChildProviders: sandbox.stub().resolves(), + updateAdminState: sandbox.stub().resolves(), + dispatchCFRAction: sandbox.stub().resolves(), + }; + sandbox.stub(router, "loadMessagesFromAllProviders").callThrough(); + return router.init(initParams); + } + + async function createRouterAndInit(providers = FAKE_PROVIDERS) { + setMessageProviderPref(providers); + // `.freeze` to catch any attempts at modifying the object + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + await initASRouter(Router); + } + + beforeEach(async () => { + globals = new GlobalOverrider(); + messageBlockList = []; + providerBlockList = []; + messageImpressions = {}; + groupImpressions = {}; + previousSessionEnd = 100; + screenImpressions = {}; + sandbox = sinon.createSandbox(); + ASRouterTargeting = { + isMatch: sandbox.stub(), + findMatchingMessage: sandbox.stub(), + Environment: { + locale: "en-US", + localeLanguageCode: "en", + browserSettings: { + update: { + channel: "default", + enabled: true, + autoDownload: true, + }, + }, + attributionData: {}, + currentDate: "2000-01-01T10:00:0.001Z", + profileAgeCreated: {}, + profileAgeReset: {}, + usesFirefoxSync: false, + isFxAEnabled: true, + isFxASignedIn: false, + sync: { + desktopDevices: 0, + mobileDevices: 0, + totalDevices: 0, + }, + xpinstallEnabled: true, + addonsInfo: {}, + searchEngines: {}, + isDefaultBrowser: false, + devToolsOpenedCount: 5, + topFrecentSites: {}, + recentBookmarks: {}, + pinnedSites: [ + { + url: "https://amazon.com", + host: "amazon.com", + searchTopSite: true, + }, + ], + providerCohorts: { + onboarding: "", + cfr: "", + "message-groups": "", + "messaging-experiments": "", + snippets: "", + "whats-new-panel": "", + }, + totalBookmarksCount: {}, + firefoxVersion: 80, + region: "US", + needsUpdate: {}, + hasPinnedTabs: false, + hasAccessedFxAPanel: false, + isWhatsNewPanelEnabled: true, + userPrefs: { + cfrFeatures: true, + cfrAddons: true, + snippets: true, + }, + totalBlockedCount: {}, + blockedCountByType: {}, + attachedFxAOAuthClients: [], + platformName: "macosx", + scores: {}, + scoreThreshold: 5000, + isChinaRepack: false, + userId: "adsf", + }, + }; + + ASRouterPreferences.specialConditions = { + someCondition: true, + }; + sandbox.spy(ASRouterPreferences, "init"); + sandbox.spy(ASRouterPreferences, "uninit"); + sandbox.spy(ASRouterPreferences, "addListener"); + sandbox.spy(ASRouterPreferences, "removeListener"); + + clock = sandbox.useFakeTimers(); + fetchStub = sandbox + .stub(global, "fetch") + .withArgs("http://fake.com/endpoint") + .resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({ messages: FAKE_REMOTE_MESSAGES }), + headers: FAKE_RESPONSE_HEADERS, + }); + getStringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref"); + + fakeAttributionCode = { + allowedCodeKeys: ["foo", "bar", "baz"], + _clearCache: () => sinon.stub(), + getAttrDataAsync: () => Promise.resolve({ content: "addonID" }), + deleteFileAsync: () => Promise.resolve(), + writeAttributionFile: () => Promise.resolve(), + getCachedAttributionData: sinon.stub(), + }; + FakeToolbarPanelHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + forceShowMessage: sandbox.stub(), + enableToolbarButton: sandbox.stub(), + }; + FakeToolbarBadgeHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + registerBadgeNotificationListener: sandbox.stub(), + }; + FakeMomentsPageHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + executeAction: sandbox.stub(), + }; + fakeTargetingContext = { + combineContexts: sandbox.stub(), + evalWithDefault: sandbox.stub().resolves(), + }; + let fakeNimbusFeatures = [ + "cfr", + "infobar", + "spotlight", + "moments-page", + "pbNewtab", + ].reduce((features, featureId) => { + features[featureId] = { + getAllVariables: sandbox.stub().returns(null), + recordExposureEvent: sandbox.stub(), + }; + return features; + }, {}); + globals.set({ + // Testing framework doesn't know how to `defineLazyModuleGetter` so we're + // importing these modules into the global scope ourselves. + GroupsConfigurationProvider: { getMessages: () => Promise.resolve([]) }, + ASRouterPreferences, + TARGETING_PREFERENCES, + ASRouterTargeting, + ASRouterTriggerListeners, + QueryCache, + gBrowser: { selectedBrowser: {} }, + gURLBar: {}, + isSeparateAboutWelcome: true, + AttributionCode: fakeAttributionCode, + SnippetsTestMessageProvider, + PanelTestProvider, + MacAttribution: { applicationPath: "" }, + ToolbarBadgeHub: FakeToolbarBadgeHub, + ToolbarPanelHub: FakeToolbarPanelHub, + MomentsPageHub: FakeMomentsPageHub, + KintoHttpClient: class { + bucket() { + return this; + } + collection() { + return this; + } + getRecord() { + return Promise.resolve({ data: {} }); + } + }, + Downloader: class { + download() { + return Promise.resolve("/path/to/download"); + } + }, + NimbusFeatures: fakeNimbusFeatures, + ExperimentAPI: { + getExperimentMetaData: sandbox.stub().returns({ + slug: "experiment-slug", + active: true, + branch: { slug: "experiment-branch-slug" }, + }), + getExperiment: sandbox.stub().returns({ + branch: { + slug: "unit-slug", + feature: { + featureId: "foo", + value: { id: "test-message" }, + }, + }, + }), + getAllBranches: sandbox.stub().resolves([]), + ready: sandbox.stub().resolves(), + }, + SpecialMessageActions: { + handleAction: sandbox.stub(), + }, + TargetingContext: class { + static combineContexts(...args) { + return fakeTargetingContext.combineContexts.apply(sandbox, args); + } + + evalWithDefault(expr) { + return fakeTargetingContext.evalWithDefault(expr); + } + }, + RemoteL10n: { + // This is just a subset of supported locales that happen to be used in + // the test. + isLocaleSupported: locale => ["en-US", "ja-JP-mac"].includes(locale), + }, + }); + await createRouterAndInit(); + }); + afterEach(() => { + Router.uninit(); + ASRouterPreferences.uninit(); + sandbox.restore(); + globals.restore(); + }); + + describe(".state", () => { + it("should throw if an attempt to set .state was made", () => { + assert.throws(() => { + Router.state = {}; + }); + }); + }); + + describe("#init", () => { + it("should only be called once", async () => { + Router = new _ASRouter(); + let state = await initASRouter(Router); + + assert.equal(state, Router.state); + + assert.isNull(await Router.init({})); + }); + it("should only be called once", async () => { + Router = new _ASRouter(); + initASRouter(Router); + let secondCall = await Router.init({}); + + assert.isNull( + secondCall, + "Should not init twice, it should exit early with null" + ); + }); + it("should set state.messageBlockList to the block list in persistent storage", async () => { + messageBlockList = ["foo"]; + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.messageBlockList, ["foo"]); + }); + it("should initialize all the hub providers", async () => { + // ASRouter init called in `beforeEach` block above + + assert.calledOnce(FakeToolbarBadgeHub.init); + assert.calledOnce(FakeToolbarPanelHub.init); + assert.calledOnce(FakeMomentsPageHub.init); + + assert.calledWithExactly( + FakeToolbarBadgeHub.init, + Router.waitForInitialized, + { + handleMessageRequest: Router.handleMessageRequest, + addImpression: Router.addImpression, + blockMessageById: Router.blockMessageById, + sendTelemetry: Router.sendTelemetry, + unblockMessageById: Router.unblockMessageById, + } + ); + + assert.calledWithExactly( + FakeToolbarPanelHub.init, + Router.waitForInitialized, + { + getMessages: Router.handleMessageRequest, + sendTelemetry: Router.sendTelemetry, + } + ); + + assert.calledWithExactly( + FakeMomentsPageHub.init, + Router.waitForInitialized, + { + handleMessageRequest: Router.handleMessageRequest, + addImpression: Router.addImpression, + blockMessageById: Router.blockMessageById, + sendTelemetry: Router.sendTelemetry, + } + ); + }); + it("should set state.messageImpressions to the messageImpressions object in persistent storage", async () => { + // Note that messageImpressions are only kept if a message exists in router and has a .frequency property, + // otherwise they will be cleaned up by .cleanupImpressions() + const testMessage = { id: "foo", frequency: { lifetimeCap: 10 } }; + messageImpressions = { foo: [0, 1, 2] }; + setMessageProviderPref([ + { id: "onboarding", type: "local", messages: [testMessage] }, + ]); + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.messageImpressions, messageImpressions); + }); + it("should set state.screenImpressions to the screenImpressions object in persistent storage", async () => { + screenImpressions = { test: 123 }; + + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.screenImpressions, screenImpressions); + }); + it("should clear impressions for groups that are not active", async () => { + groupImpressions = { foo: [0, 1, 2] }; + Router = new _ASRouter(); + await initASRouter(Router); + + assert.notProperty(Router.state.groupImpressions, "foo"); + }); + it("should keep impressions for groups that are active", async () => { + Router = new _ASRouter(); + await initASRouter(Router); + await Router.setState(() => { + return { + groups: [ + { + id: "foo", + enabled: true, + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 10 }], + lifetime: Infinity, + }, + }, + ], + groupImpressions: { foo: [Date.now()] }, + }; + }); + Router.cleanupImpressions(); + + assert.property(Router.state.groupImpressions, "foo"); + assert.lengthOf(Router.state.groupImpressions.foo, 1); + }); + it("should remove old impressions for a group", async () => { + Router = new _ASRouter(); + await initASRouter(Router); + await Router.setState(() => { + return { + groups: [ + { + id: "foo", + enabled: true, + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 10 }], + }, + }, + ], + groupImpressions: { + foo: [Date.now() - ONE_DAY_IN_MS - 1, Date.now()], + }, + }; + }); + Router.cleanupImpressions(); + + assert.property(Router.state.groupImpressions, "foo"); + assert.lengthOf(Router.state.groupImpressions.foo, 1); + }); + it("should await .loadMessagesFromAllProviders() and add messages from providers to state.messages", async () => { + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + + await initASRouter(Router); + + assert.calledOnce(Router.loadMessagesFromAllProviders); + assert.isArray(Router.state.messages); + assert.lengthOf( + Router.state.messages, + FAKE_LOCAL_MESSAGES.length + FAKE_REMOTE_MESSAGES.length + ); + }); + it("should load additional allowed hosts", async () => { + getStringPrefStub.returns('["allow.com"]'); + await createRouterAndInit(); + + assert.propertyVal(Router.ALLOWLIST_HOSTS, "allow.com", "preview"); + // Should still include the defaults + assert.lengthOf(Object.keys(Router.ALLOWLIST_HOSTS), 3); + }); + it("should fallback to defaults if pref parsing fails", async () => { + getStringPrefStub.returns("err"); + await createRouterAndInit(); + + assert.lengthOf(Object.keys(Router.ALLOWLIST_HOSTS), 2); + assert.propertyVal( + Router.ALLOWLIST_HOSTS, + "snippets-admin.mozilla.org", + "preview" + ); + assert.propertyVal( + Router.ALLOWLIST_HOSTS, + "activity-stream-icons.services.mozilla.com", + "production" + ); + }); + it("should set state.previousSessionEnd from IndexedDB", async () => { + previousSessionEnd = 200; + await createRouterAndInit(); + + assert.equal(Router.state.previousSessionEnd, previousSessionEnd); + }); + it("should assign ASRouterPreferences.specialConditions to state", async () => { + assert.isTrue(ASRouterPreferences.specialConditions.someCondition); + assert.isTrue(Router.state.someCondition); + }); + it("should add observer for `intl:app-locales-changed`", async () => { + sandbox.spy(global.Services.obs, "addObserver"); + await createRouterAndInit(); + + assert.calledWithExactly( + global.Services.obs.addObserver, + Router._onLocaleChanged, + "intl:app-locales-changed" + ); + }); + it("should add a pref observer", async () => { + sandbox.spy(global.Services.prefs, "addObserver"); + await createRouterAndInit(); + + assert.calledOnce(global.Services.prefs.addObserver); + assert.calledWithExactly( + global.Services.prefs.addObserver, + USE_REMOTE_L10N_PREF, + Router + ); + }); + describe("lazily loading local test providers", () => { + afterEach(() => { + Router.uninit(); + }); + it("should add the local test providers on init if devtools are enabled", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true); + + await createRouterAndInit(); + + assert.property(Router._localProviders, "SnippetsTestMessageProvider"); + assert.property(Router._localProviders, "PanelTestProvider"); + }); + it("should not add the local test providers on init if devtools are disabled", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false); + + await createRouterAndInit(); + + assert.notProperty( + Router._localProviders, + "SnippetsTestMessageProvider" + ); + assert.notProperty(Router._localProviders, "PanelTestProvider"); + }); + }); + }); + + describe("preference changes", () => { + it("should call ASRouterPreferences.init and add a listener on init", () => { + assert.calledOnce(ASRouterPreferences.init); + assert.calledWith(ASRouterPreferences.addListener, Router.onPrefChange); + }); + it("should call ASRouterPreferences.uninit and remove the listener on uninit", () => { + Router.uninit(); + assert.calledOnce(ASRouterPreferences.uninit); + assert.calledWith( + ASRouterPreferences.removeListener, + Router.onPrefChange + ); + }); + it("should send a AS_ROUTER_TARGETING_UPDATE message", async () => { + const messageTargeted = { + id: "1", + campaign: "foocampaign", + targeting: "true", + groups: ["snippets"], + provider: "snippets", + }; + const messageNotTargeted = { + id: "2", + campaign: "foocampaign", + groups: ["snippets"], + provider: "snippets", + }; + await Router.setState({ + messages: [messageTargeted, messageNotTargeted], + providers: [{ id: "snippets" }], + }); + fakeTargetingContext.evalWithDefault.resolves(false); + + await Router.onPrefChange("services.sync.username"); + + assert.calledOnce(initParams.clearChildMessages); + assert.calledWith(initParams.clearChildMessages, [messageTargeted.id]); + }); + it("should call loadMessagesFromAllProviders on pref change", () => { + ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME); + assert.calledOnce(Router.loadMessagesFromAllProviders); + }); + it("should update groups state if a user pref changes", async () => { + await Router.setState({ + groups: [{ id: "foo", userPreferences: ["bar"], enabled: true }], + }); + sandbox.stub(ASRouterPreferences, "getUserPreference"); + + await Router.onPrefChange(MESSAGE_PROVIDER_PREF_NAME); + + assert.calledWithExactly(ASRouterPreferences.getUserPreference, "bar"); + }); + it("should update the list of providers on pref change", async () => { + const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, { + url: "baz.com", + }); + setMessageProviderPref([ + FAKE_LOCAL_PROVIDER, + modifiedRemoteProvider, + FAKE_REMOTE_SETTINGS_PROVIDER, + ]); + + const { length } = Router.state.providers; + + ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME); + await Router._updateMessageProviders(); + + const provider = Router.state.providers.find(p => p.url === "baz.com"); + assert.lengthOf(Router.state.providers, length); + assert.isDefined(provider); + }); + it("should clear disabled providers on pref change", async () => { + const TEST_PROVIDER_ID = "some_provider_id"; + await Router.setState({ + providers: [{ id: TEST_PROVIDER_ID }], + }); + const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, { + id: TEST_PROVIDER_ID, + enabled: false, + }); + setMessageProviderPref([ + FAKE_LOCAL_PROVIDER, + modifiedRemoteProvider, + FAKE_REMOTE_SETTINGS_PROVIDER, + ]); + await Router.onPrefChange(MESSAGE_PROVIDER_PREF_NAME); + + assert.calledOnce(initParams.clearChildProviders); + assert.calledWith(initParams.clearChildProviders, [TEST_PROVIDER_ID]); + }); + }); + + describe("setState", () => { + it("should broadcast a message to update the admin tool on a state change if the asrouter.devtoolsEnabled pref is", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true); + sandbox.stub(Router, "getTargetingParameters").resolves({}); + const state = await Router.setState({ foo: 123 }); + + assert.calledOnce(initParams.updateAdminState); + assert.deepEqual(state.providerPrefs, ASRouterPreferences.providers); + assert.deepEqual( + state.userPrefs, + ASRouterPreferences.getAllUserPreferences() + ); + assert.deepEqual(state.targetingParameters, {}); + assert.deepEqual(state.errors, Router.errors); + }); + it("should not send a message on a state change asrouter.devtoolsEnabled pref is on", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false); + await Router.setState({ foo: 123 }); + + assert.notCalled(initParams.updateAdminState); + }); + }); + + describe("getTargetingParameters", () => { + it("should return the targeting parameters", async () => { + const stub = sandbox.stub().resolves("foo"); + const obj = { foo: 1 }; + sandbox.stub(obj, "foo").get(stub); + const result = await Router.getTargetingParameters(obj, obj); + + assert.calledTwice(stub); + assert.propertyVal(result, "foo", "foo"); + }); + }); + + describe("evaluateExpression", () => { + it("should call ASRouterTargeting to evaluate", async () => { + fakeTargetingContext.evalWithDefault.resolves("foo"); + const response = await Router.evaluateExpression({}); + assert.equal(response.evaluationStatus.result, "foo"); + assert.isTrue(response.evaluationStatus.success); + }); + it("should catch evaluation errors", async () => { + fakeTargetingContext.evalWithDefault.returns( + Promise.reject(new Error("fake error")) + ); + const response = await Router.evaluateExpression({}); + assert.isFalse(response.evaluationStatus.success); + }); + }); + + describe("#routeCFRMessage", () => { + let browser; + beforeEach(() => { + sandbox.stub(CFRPageActions, "forceRecommendation"); + sandbox.stub(CFRPageActions, "addRecommendation"); + browser = {}; + }); + it("should route whatsnew_panel_message message to the right hub", () => { + Router.routeCFRMessage( + { template: "whatsnew_panel_message" }, + browser, + "", + true + ); + + assert.calledOnce(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route moments messages to the right hub", () => { + Router.routeCFRMessage({ template: "update_action" }, browser, "", true); + + assert.calledOnce(FakeMomentsPageHub.executeAction); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + }); + it("should route toolbar_badge message to the right hub", () => { + Router.routeCFRMessage({ template: "toolbar_badge" }, browser); + + assert.calledOnce(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route milestone_message to the right hub", () => { + Router.routeCFRMessage( + { template: "milestone_message" }, + browser, + "", + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_doorhanger message to the right hub force = false", () => { + Router.routeCFRMessage( + { template: "cfr_doorhanger" }, + browser, + { param: {} }, + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_doorhanger message to the right hub force = true", () => { + Router.routeCFRMessage({ template: "cfr_doorhanger" }, browser, {}, true); + + assert.calledOnce(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_urlbar_chiclet message to the right hub force = false", () => { + Router.routeCFRMessage( + { template: "cfr_urlbar_chiclet" }, + browser, + { param: {} }, + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + const { args } = CFRPageActions.addRecommendation.firstCall; + // Host should be null + assert.isNull(args[1]); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_urlbar_chiclet message to the right hub force = true", () => { + Router.routeCFRMessage( + { template: "cfr_urlbar_chiclet" }, + browser, + {}, + true + ); + + assert.calledOnce(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route default to sending to content", () => { + Router.routeCFRMessage({ template: "snippets" }, browser, {}, true); + + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + }); + + describe("#loadMessagesFromAllProviders", () => { + function assertRouterContainsMessages(messages) { + const messageIdsInRouter = Router.state.messages.map(m => m.id); + for (const message of messages) { + assert.include(messageIdsInRouter, message.id); + } + } + + it("should not trigger an update if not enough time has passed for a provider", async () => { + await createRouterAndInit([ + { + id: "remotey", + type: "remote", + enabled: true, + url: "http://fake.com/endpoint", + updateCycleInMs: 300, + }, + ]); + + const previousState = Router.state; + + // Since we've previously gotten messages during init and we haven't advanced our fake timer, + // no updates should be triggered. + await Router.loadMessagesFromAllProviders(); + assert.deepEqual(Router.state, previousState); + }); + it("should not trigger an update if we only have local providers", async () => { + await createRouterAndInit([ + { + id: "foo", + type: "local", + enabled: true, + messages: FAKE_LOCAL_MESSAGES, + }, + ]); + + const previousState = Router.state; + const stub = sandbox.stub(MessageLoaderUtils, "loadMessagesForProvider"); + + clock.tick(300); + + await Router.loadMessagesFromAllProviders(); + + assert.deepEqual(Router.state, previousState); + assert.notCalled(stub); + }); + it("should update messages for a provider if enough time has passed, without removing messages for other providers", async () => { + const NEW_MESSAGES = [{ id: "new_123" }]; + await createRouterAndInit([ + { + id: "remotey", + type: "remote", + url: "http://fake.com/endpoint", + enabled: true, + updateCycleInMs: 300, + }, + { + id: "alocalprovider", + type: "local", + enabled: true, + messages: FAKE_LOCAL_MESSAGES, + }, + ]); + fetchStub.withArgs("http://fake.com/endpoint").resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({ messages: NEW_MESSAGES }), + headers: FAKE_RESPONSE_HEADERS, + }); + + clock.tick(301); + await Router.loadMessagesFromAllProviders(); + + // These are the new messages + assertRouterContainsMessages(NEW_MESSAGES); + // These are the local messages that should not have been deleted + assertRouterContainsMessages(FAKE_LOCAL_MESSAGES); + }); + it("should parse the triggers in the messages and register the trigger listeners", async () => { + sandbox.spy( + ASRouterTriggerListeners.get("openURL"), + "init" + ); /* eslint-disable object-property-newline */ + + /* eslint-disable object-curly-newline */ await createRouterAndInit([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + { + id: "foo", + template: "simple_template", + trigger: { id: "firstRun" }, + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "bar1", + template: "simple_template", + trigger: { + id: "openURL", + params: ["www.mozilla.org", "www.mozilla.com"], + }, + content: { title: "Bar1", body: "Bar123" }, + }, + { + id: "bar2", + template: "simple_template", + trigger: { id: "openURL", params: ["www.example.com"] }, + content: { title: "Bar2", body: "Bar123" }, + }, + ], + }, + ]); /* eslint-enable object-property-newline */ + /* eslint-enable object-curly-newline */ assert.calledTwice( + ASRouterTriggerListeners.get("openURL").init + ); + assert.calledWithExactly( + ASRouterTriggerListeners.get("openURL").init, + Router._triggerHandler, + ["www.mozilla.org", "www.mozilla.com"], + undefined + ); + assert.calledWithExactly( + ASRouterTriggerListeners.get("openURL").init, + Router._triggerHandler, + ["www.example.com"], + undefined + ); + }); + it("should parse the message's messagesLoaded trigger and immediately fire trigger", async () => { + setMessageProviderPref([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + { + id: "bar3", + template: "simple_template", + trigger: { id: "messagesLoaded" }, + content: { title: "Bar3", body: "Bar123" }, + }, + ], + }, + ]); + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + sandbox.spy(Router, "sendTriggerMessage"); + await initASRouter(Router); + assert.calledOnce(Router.sendTriggerMessage); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ id: "messagesLoaded" }), + true + ); + }); + it("should gracefully handle messages loading before a window or browser exists", async () => { + sandbox.stub(global, "gBrowser").value(undefined); + sandbox + .stub(global.Services.wm, "getMostRecentBrowserWindow") + .returns(undefined); + setMessageProviderPref([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + "whatsnew_panel_message", + "cfr_doorhanger", + "toolbar_badge", + "update_action", + "infobar", + "spotlight", + "toast_notification", + ].map((template, i) => { + return { + id: `foo${i}`, + template, + trigger: { id: "messagesLoaded" }, + content: { title: `Foo${i}`, body: "Bar123" }, + }; + }), + }, + ]); + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + sandbox.spy(Router, "sendTriggerMessage"); + await initASRouter(Router); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ id: "messagesLoaded" }), + true + ); + }); + it("should gracefully handle RemoteSettings blowing up and dispatch undesired event", async () => { + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .rejects("fake error"); + await createRouterAndInit(); + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_ERROR", + event_context: "remotey-settingsy", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + it("should dispatch undesired event if RemoteSettings returns no messages", async () => { + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([]); + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_NO_MESSAGES", + event_context: "remotey-settingsy", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + it("should download the attachment if RemoteSettings returns some messages", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + const spy = sandbox.spy(); + global.Downloader.prototype.downloadToDisk = spy; + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + + assert.calledOnce(spy); + }); + it("should dispatch undesired event if the ms-language-packs returns no messages", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + sandbox + .stub(global.KintoHttpClient.prototype, "getRecord") + .resolves(null); + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_NO_MESSAGES", + event_context: "ms-language-packs", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + }); + + describe("#_updateMessageProviders", () => { + it("should correctly replace %STARTPAGE_VERSION% in remote provider urls", async () => { + // If this test fails, you need to update the constant STARTPAGE_VERSION in + // ASRouter.jsm to match the `version` property of provider-response-schema.json + const expectedStartpageVersion = ProviderResponseSchema.version; + const provider = { + id: "foo", + enabled: true, + type: "remote", + url: "https://www.mozilla.org/%STARTPAGE_VERSION%/", + }; + setMessageProviderPref([provider]); + await Router._updateMessageProviders(); + assert.equal( + Router.state.providers[0].url, + `https://www.mozilla.org/${parseInt(expectedStartpageVersion, 10)}/` + ); + }); + it("should replace other params in remote provider urls by calling Services.urlFormater.formatURL", async () => { + const url = "https://www.example.com/"; + const replacedUrl = "https://www.foo.bar/"; + const stub = sandbox + .stub(global.Services.urlFormatter, "formatURL") + .withArgs(url) + .returns(replacedUrl); + const provider = { id: "foo", enabled: true, type: "remote", url }; + setMessageProviderPref([provider]); + await Router._updateMessageProviders(); + assert.calledOnce(stub); + assert.calledWithExactly(stub, url); + assert.equal(Router.state.providers[0].url, replacedUrl); + }); + it("should only add the providers that are enabled", async () => { + const providers = [ + { + id: "foo", + enabled: false, + type: "remote", + url: "https://www.foo.com/", + }, + { + id: "bar", + enabled: true, + type: "remote", + url: "https://www.bar.com/", + }, + ]; + setMessageProviderPref(providers); + await Router._updateMessageProviders(); + assert.equal(Router.state.providers.length, 1); + assert.equal(Router.state.providers[0].id, providers[1].id); + }); + }); + + describe("#handleMessageRequest", () => { + beforeEach(async () => { + await Router.setState(() => ({ + providers: [{ id: "snippets" }, { id: "badge" }], + })); + }); + it("should not return a blocked message", async () => { + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { id: "foo", provider: "snippets", groups: ["snippets"] }, + { id: "bar", provider: "snippets", groups: ["snippets"] }, + ], + messageBlockList: ["foo"], + })); + await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, { + messages: [{ id: "bar", provider: "snippets", groups: ["snippets"] }], + }); + }); + it("should not return a message from a disabled group", async () => { + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { id: "foo", provider: "snippets", groups: ["snippets"] }, + { id: "bar", provider: "snippets", groups: ["snippets"] }, + ], + groups: [{ id: "snippets", enabled: false }], + })); + const result = await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.isNull(result); + }); + it("should not return a message from a blocked campaign", async () => { + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { + id: "foo", + provider: "snippets", + campaign: "foocampaign", + groups: ["snippets"], + }, + { id: "bar", provider: "snippets", groups: ["snippets"] }, + ], + messageBlockList: ["foocampaign"], + })); + + await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, { + messages: [{ id: "bar", provider: "snippets", groups: ["snippets"] }], + }); + }); + it("should not return a message excluded by the provider", async () => { + // There are only two providers; block the FAKE_LOCAL_PROVIDER, leaving + // only FAKE_REMOTE_PROVIDER unblocked, which provides only one message + await Router.setState(() => ({ + providers: [{ id: "snippets", exclude: ["foo"] }], + })); + + await Router.setState(() => ({ + messages: [{ id: "foo", provider: "snippets" }], + messageBlockList: ["foocampaign"], + })); + + const result = await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.isNull(result); + }); + it("should not return a message if the frequency cap has been hit", async () => { + sandbox.stub(Router, "isBelowFrequencyCaps").returns(false); + await Router.setState(() => ({ + messages: [{ id: "foo", provider: "snippets" }], + })); + const result = await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.isNull(result); + }); + it("should get unblocked messages that match the trigger", async () => { + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + provider: "snippets", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["snippets"], + provider: "snippets", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + + const result = Router.handleMessageRequest({ triggerId: "foo" }); + + assert.deepEqual(result, message1); + }); + it("should get unblocked messages that match trigger and template", async () => { + const message1 = { + id: "1", + campaign: "foocampaign", + template: "badge", + trigger: { id: "foo" }, + groups: ["badge"], + provider: "badge", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + template: "snippet", + trigger: { id: "foo" }, + groups: ["snippets"], + provider: "snippets", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + + const result = Router.handleMessageRequest({ + triggerId: "foo", + template: "badge", + }); + + assert.deepEqual(result, message1); + }); + it("should have messageImpressions in the message context", () => { + assert.propertyVal( + Router._getMessagesContext(), + "messageImpressions", + Router.state.messageImpressions + ); + }); + it("should return all unblocked messages that match the template, trigger if returnAll=true", async () => { + const message1 = { + provider: "whats_new", + id: "1", + template: "whatsnew_panel_message", + trigger: { id: "whatsNewPanelOpened" }, + groups: ["whats_new"], + }; + const message2 = { + provider: "whats_new", + id: "2", + template: "whatsnew_panel_message", + trigger: { id: "whatsNewPanelOpened" }, + groups: ["whats_new"], + }; + const message3 = { + provider: "whats_new", + id: "3", + template: "badge", + groups: ["whats_new"], + }; + ASRouterTargeting.findMatchingMessage.callsFake(() => [ + message2, + message1, + ]); + await Router.setState({ + messages: [message3, message2, message1], + providers: [{ id: "whats_new" }], + }); + const result = await Router.handleMessageRequest({ + template: "whatsnew_panel_message", + triggerId: "whatsNewPanelOpened", + returnAll: true, + }); + + assert.deepEqual(result, [message2, message1]); + }); + it("should forward trigger param info", async () => { + const trigger = { + triggerId: "foo", + triggerParam: "bar", + triggerContext: "context", + }; + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + provider: "snippets", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["badge"], + provider: "badge", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + + Router.handleMessageRequest(trigger); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + + const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args; + assert.propertyVal(options.trigger, "id", trigger.triggerId); + assert.propertyVal(options.trigger, "param", trigger.triggerParam); + assert.propertyVal(options.trigger, "context", trigger.triggerContext); + }); + it("should cache snippets messages", async () => { + const trigger = { + triggerId: "foo", + triggerParam: "bar", + triggerContext: "context", + }; + const message1 = { + id: "1", + provider: "snippets", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["snippets"], + }; + await Router.setState({ messages: [message2, message1] }); + + Router.handleMessageRequest(trigger); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + + const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args; + assert.propertyVal(options, "shouldCache", true); + }); + it("should not cache badge messages", async () => { + const trigger = { + triggerId: "bar", + triggerParam: "bar", + triggerContext: "context", + }; + const message1 = { + id: "1", + provider: "snippets", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["badge"], + provider: "badge", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + + Router.handleMessageRequest(trigger); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + + const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args; + assert.propertyVal(options, "shouldCache", false); + }); + it("should filter out messages without a trigger (or different) when a triggerId is defined", async () => { + const trigger = { triggerId: "foo" }; + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + provider: "snippets", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["snippets"], + provider: "snippets", + }; + const message3 = { + id: "3", + campaign: "bazcampaign", + groups: ["snippets"], + provider: "snippets", + }; + await Router.setState({ + messages: [message2, message1, message3], + groups: [{ id: "snippets", enabled: true }], + }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake(args => args.messages); + + const result = Router.handleMessageRequest(trigger); + + assert.lengthOf(result, 1); + assert.deepEqual(result[0], message1); + }); + }); + + describe("#uninit", () => { + it("should unregister the trigger listeners", () => { + for (const listener of ASRouterTriggerListeners.values()) { + sandbox.spy(listener, "uninit"); + } + + Router.uninit(); + + for (const listener of ASRouterTriggerListeners.values()) { + assert.calledOnce(listener.uninit); + } + }); + it("should set .dispatchCFRAction to null", () => { + Router.uninit(); + assert.isNull(Router.dispatchCFRAction); + assert.isNull(Router.clearChildMessages); + assert.isNull(Router.sendTelemetry); + }); + it("should save previousSessionEnd", () => { + Router.uninit(); + + assert.calledOnce(Router._storage.set); + assert.calledWithExactly( + Router._storage.set, + "previousSessionEnd", + sinon.match.number + ); + }); + it("should remove the observer for `intl:app-locales-changed`", () => { + sandbox.spy(global.Services.obs, "removeObserver"); + Router.uninit(); + + assert.calledWithExactly( + global.Services.obs.removeObserver, + Router._onLocaleChanged, + "intl:app-locales-changed" + ); + }); + it("should remove the pref observer for `USE_REMOTE_L10N_PREF`", async () => { + sandbox.spy(global.Services.prefs, "removeObserver"); + Router.uninit(); + + // Grab the last call as #uninit() also involves multiple calls of `Services.prefs.removeObserver`. + const call = global.Services.prefs.removeObserver.lastCall; + assert.calledWithExactly(call, USE_REMOTE_L10N_PREF, Router); + }); + }); + + describe("sendNewTabMessage", () => { + it("should construct an appropriate response message", async () => { + Router.loadMessagesFromAllProviders.resetHistory(); + Router.loadMessagesFromAllProviders.onFirstCall().resolves(); + + let message = { + id: "foo", + provider: "snippets", + groups: ["snippets"], + }; + + await Router.setState({ + messages: [message], + providers: [{ id: "snippets" }], + }); + + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + + let response = await Router.sendNewTabMessage({ + tabId: 0, + browser: {}, + }); + + assert.deepEqual(response.message, message); + }); + it("should send an empty object message if no messages are available", async () => { + await Router.setState({ messages: [] }); + let response = await Router.sendNewTabMessage({ + tabId: 0, + browser: {}, + }); + + assert.deepEqual(response.message, {}); + }); + + describe("#addPreviewEndpoint", () => { + it("should make a request to the provided endpoint", async () => { + const url = "https://snippets-admin.mozilla.org/foo"; + const browser = {}; + browser.sendMessageToActor = sandbox.stub(); + + await Router.sendNewTabMessage({ + endpoint: { url }, + tabId: 0, + browser, + }); + + assert.calledWith(global.fetch, url); + assert.lengthOf( + Router.state.providers.filter(p => p.url === url), + 0 + ); + }); + it("should send EnterSnippetPreviewMode when adding a preview endpoint", async () => { + const url = "https://snippets-admin.mozilla.org/foo"; + const browser = {}; + browser.sendMessageToActor = sandbox.stub(); + + await Router.addPreviewEndpoint(url, browser); + + assert.calledWithExactly( + browser.sendMessageToActor, + "EnterSnippetsPreviewMode", + {}, + "ASRouter" + ); + }); + it("should not add a url that is not from an allowed host", async () => { + const url = "https://mozilla.org"; + const browser = {}; + browser.sendMessageToActor = sandbox.stub(); + + await Router.addPreviewEndpoint(url, browser); + + assert.lengthOf( + Router.state.providers.filter(p => p.url === url), + 0 + ); + }); + it("should reject bad urls", async () => { + const url = "foo"; + const browser = {}; + browser.sendMessageToActor = sandbox.stub(); + + await Router.addPreviewEndpoint(url, browser); + + assert.lengthOf( + Router.state.providers.filter(p => p.url === url), + 0 + ); + }); + }); + + it("should record telemetry for message request duration", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + sandbox.stub(Router, "handleMessageRequest"); + const tabId = 123; + await Router.sendNewTabMessage({ + tabId, + browser: {}, + }); + + // Called once for the messagesLoaded trigger and once for the above call. + assert.calledTwice(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + assert.calledTwice(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + }); + it("should return the preview message if that's available and remove it from Router.state", async () => { + const expectedObj = { + id: "foo", + groups: ["preview"], + provider: "preview", + }; + await Router.setState({ + messages: [expectedObj], + providers: [{ id: "preview" }], + }); + + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => expectedObj + ); + + Router.loadMessagesFromAllProviders.resetHistory(); + Router.loadMessagesFromAllProviders.onFirstCall().resolves(); + + let response = await Router.sendNewTabMessage({ + endpoint: { url: "foo.com" }, + tabId: 0, + browser: {}, + }); + + assert.deepEqual(response.message, expectedObj); + + assert.isUndefined( + Router.state.messages.find(m => m.provider === "preview") + ); + }); + }); + + describe("#setMessageById", async () => { + it("should send an empty message if provided id did not resolve to a message", async () => { + let response = await Router.setMessageById({ id: -1 }, true, {}); + assert.deepEqual(response.message, {}); + }); + }); + + describe("#isUnblockedMessage", () => { + it("should block a message if the group is blocked", async () => { + const msg = { id: "msg1", groups: ["foo"], provider: "unit-test" }; + await Router.setState({ + groups: [{ id: "foo", enabled: false }], + messages: [msg], + providers: [{ id: "unit-test" }], + }); + assert.isFalse(Router.isUnblockedMessage(msg)); + + await Router.setState({ groups: [{ id: "foo", enabled: true }] }); + + assert.isTrue(Router.isUnblockedMessage(msg)); + }); + it("should block a message if at least one group is blocked", async () => { + const msg = { + id: "msg1", + groups: ["foo", "bar"], + provider: "unit-test", + }; + await Router.setState({ + groups: [ + { id: "foo", enabled: false }, + { id: "bar", enabled: false }, + ], + messages: [msg], + providers: [{ id: "unit-test" }], + }); + assert.isFalse(Router.isUnblockedMessage(msg)); + + await Router.setState({ + groups: [ + { id: "foo", enabled: true }, + { id: "bar", enabled: false }, + ], + }); + + assert.isFalse(Router.isUnblockedMessage(msg)); + }); + }); + + describe("#blockMessageById", () => { + it("should add the id to the messageBlockList", async () => { + await Router.blockMessageById("foo"); + assert.isTrue(Router.state.messageBlockList.includes("foo")); + }); + it("should add the campaign to the messageBlockList instead of id if .campaign is specified and not select messages of that campaign again", async () => { + await Router.setState({ + messages: [ + { id: "1", campaign: "foocampaign" }, + { id: "2", campaign: "foocampaign" }, + ], + }); + await Router.blockMessageById("1"); + + assert.isTrue(Router.state.messageBlockList.includes("foocampaign")); + assert.isEmpty(Router.state.messages.filter(Router.isUnblockedMessage)); + }); + it("should be able to add multiple items to the messageBlockList", async () => { + await await Router.blockMessageById(FAKE_BUNDLE.map(b => b.id)); + assert.isTrue(Router.state.messageBlockList.includes(FAKE_BUNDLE[0].id)); + assert.isTrue(Router.state.messageBlockList.includes(FAKE_BUNDLE[1].id)); + }); + it("should save the messageBlockList", async () => { + await await Router.blockMessageById(FAKE_BUNDLE.map(b => b.id)); + assert.calledWithExactly(Router._storage.set, "messageBlockList", [ + FAKE_BUNDLE[0].id, + FAKE_BUNDLE[1].id, + ]); + }); + }); + + describe("#unblockMessageById", () => { + it("should remove the id from the messageBlockList", async () => { + await Router.blockMessageById("foo"); + assert.isTrue(Router.state.messageBlockList.includes("foo")); + await Router.unblockMessageById("foo"); + assert.isFalse(Router.state.messageBlockList.includes("foo")); + }); + it("should remove the campaign from the messageBlockList if it is defined", async () => { + await Router.setState({ messages: [{ id: "1", campaign: "foo" }] }); + await Router.blockMessageById("1"); + assert.isTrue( + Router.state.messageBlockList.includes("foo"), + "blocklist has campaign id" + ); + await Router.unblockMessageById("1"); + assert.isFalse( + Router.state.messageBlockList.includes("foo"), + "campaign id removed from blocklist" + ); + }); + it("should save the messageBlockList", async () => { + await Router.unblockMessageById("foo"); + assert.calledWithExactly(Router._storage.set, "messageBlockList", []); + }); + }); + + describe("#routeCFRMessage", () => { + it("should allow for echoing back message modifications", () => { + const message = { somekey: "some value" }; + const data = { content: message }; + const browser = {}; + let msg = Router.routeCFRMessage(data.content, browser, data, false); + assert.deepEqual(msg.message, message); + }); + it("should call CFRPageActions.forceRecommendation if the template is cfr_action and force is true", async () => { + sandbox.stub(CFRPageActions, "forceRecommendation"); + const testMessage = { id: "foo", template: "cfr_doorhanger" }; + await Router.setState({ messages: [testMessage] }); + Router.routeCFRMessage(testMessage, {}, null, true); + + assert.calledOnce(CFRPageActions.forceRecommendation); + }); + it("should call CFRPageActions.addRecommendation if the template is cfr_action and force is false", async () => { + sandbox.stub(CFRPageActions, "addRecommendation"); + const testMessage = { id: "foo", template: "cfr_doorhanger" }; + await Router.setState({ messages: [testMessage] }); + Router.routeCFRMessage(testMessage, {}, {}, false); + assert.calledOnce(CFRPageActions.addRecommendation); + }); + }); + + describe("#updateTargetingParameters", () => { + it("should return an object containing the whole state", async () => { + sandbox.stub(Router, "getTargetingParameters").resolves({}); + let msg = await Router.updateTargetingParameters(); + let expected = Object.assign({}, Router.state, { + providerPrefs: ASRouterPreferences.providers, + userPrefs: ASRouterPreferences.getAllUserPreferences(), + targetingParameters: {}, + errors: Router.errors, + }); + + assert.deepEqual(msg, expected); + }); + }); + + describe("#reachEvent", () => { + let experimentAPIStub; + let featureIds = ["cfr", "moments-page", "infobar", "spotlight"]; + beforeEach(() => { + let getExperimentMetaDataStub = sandbox.stub(); + let getAllBranchesStub = sandbox.stub(); + featureIds.forEach(feature => { + global.NimbusFeatures[feature].getAllVariables.returns({ + id: `message-${feature}`, + }); + getExperimentMetaDataStub.withArgs({ featureId: feature }).returns({ + slug: `slug-${feature}`, + branch: { + slug: `branch-${feature}`, + }, + }); + getAllBranchesStub.withArgs(`slug-${feature}`).resolves([ + { + slug: `other-branch-${feature}`, + [feature]: { value: { trigger: "unit-test" } }, + }, + ]); + }); + experimentAPIStub = { + getExperimentMetaData: getExperimentMetaDataStub, + getAllBranches: getAllBranchesStub, + }; + globals.set("ExperimentAPI", experimentAPIStub); + }); + afterEach(() => { + sandbox.restore(); + }); + it("should tag `forReachEvent` for all the expected message types", async () => { + // This should match the `providers.messaging-experiments` + let response = await MessageLoaderUtils.loadMessagesForProvider({ + type: "remote-experiments", + featureIds, + }); + + // 1 message for reach 1 for expose + assert.property(response, "messages"); + assert.lengthOf(response.messages, featureIds.length * 2); + assert.lengthOf( + response.messages.filter(m => m.forReachEvent), + featureIds.length + ); + }); + }); + + describe("#sendTriggerMessage", () => { + it("should pass the trigger to ASRouterTargeting when sending trigger message", async () => { + await Router.setState({ + messages: [ + { + id: "foo1", + provider: "onboarding", + template: "onboarding", + trigger: { id: "firstRun" }, + content: { title: "Foo1", body: "Foo123-1" }, + groups: ["onboarding"], + }, + ], + providers: [{ id: "onboarding" }], + }); + + Router.loadMessagesFromAllProviders.resetHistory(); + Router.loadMessagesFromAllProviders.onFirstCall().resolves(); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "firstRun", + }); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + assert.deepEqual( + ASRouterTargeting.findMatchingMessage.firstCall.args[0].trigger, + { + id: "firstRun", + param: undefined, + context: undefined, + } + ); + }); + it("should record telemetry information", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + + const tabId = 123; + + await Router.sendTriggerMessage({ + tabId, + browser: {}, + id: "firstRun", + }); + + assert.calledTwice(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + assert.calledTwice(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + }); + it("should have previousSessionEnd in the message context", () => { + assert.propertyVal( + Router._getMessagesContext(), + "previousSessionEnd", + 100 + ); + }); + it("should record the Reach event if found any", async () => { + let messages = [ + { + id: "foo1", + forReachEvent: { sent: false, group: "cfr" }, + experimentSlug: "exp01", + branchSlug: "branch01", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + { + id: "foo2", + template: "simple_template", + trigger: { id: "bar" }, + content: { title: "Foo2", body: "Foo123-2" }, + provider: "onboarding", + }, + { + id: "foo3", + forReachEvent: { sent: false, group: "cfr" }, + experimentSlug: "exp02", + branchSlug: "branch02", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + sandbox.spy(Services.telemetry, "recordEvent"); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + + assert.calledTwice(Services.telemetry.recordEvent); + }); + it("should not record the Reach event if it's already sent", async () => { + let messages = [ + { + id: "foo1", + forReachEvent: { sent: true, group: "cfr" }, + experimentSlug: "exp01", + branchSlug: "branch01", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + sandbox.spy(Services.telemetry, "recordEvent"); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + assert.notCalled(Services.telemetry.recordEvent); + }); + it("should record the Exposure event for each valid feature", async () => { + ["cfr_doorhanger", "update_action", "infobar", "spotlight"].forEach( + async template => { + let featureMap = { + cfr_doorhanger: "cfr", + spotlight: "spotlight", + infobar: "infobar", + update_action: "moments-page", + }; + assert.notCalled( + global.NimbusFeatures[featureMap[template]].recordExposureEvent + ); + + let messages = [ + { + id: "foo1", + template, + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + + assert.calledOnce( + global.NimbusFeatures[featureMap[template]].recordExposureEvent + ); + } + ); + }); + }); + + describe("forceAttribution", () => { + let setReferrerUrl; + beforeEach(() => { + setReferrerUrl = sinon.spy(); + global.Cc["@mozilla.org/mac-attribution;1"] = { + getService: () => ({ setReferrerUrl }), + }; + + sandbox.stub(global.Services.env, "set"); + }); + it("should double encode on windows", async () => { + sandbox.stub(fakeAttributionCode, "writeAttributionFile"); + + Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" }); + + assert.notCalled(setReferrerUrl); + assert.calledWithMatch( + fakeAttributionCode.writeAttributionFile, + "foo%3DFOO!%26bar%3DBAR%253F" + ); + }); + it("should set referrer on mac", async () => { + sandbox.stub(global.AppConstants, "platform").value("macosx"); + + Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" }); + + assert.calledOnce(setReferrerUrl); + assert.calledWithMatch(setReferrerUrl, "", "?foo=FOO!&bar=BAR%3F"); + }); + }); + + describe("#forceWNPanel", () => { + let browser = { + ownerGlobal: { + document: new Document(), + PanelUI: { + showSubView: sinon.stub(), + panel: { + setAttribute: sinon.stub(), + }, + }, + }, + }; + let fakePanel = { + setAttribute: sinon.stub(), + }; + sinon + .stub(browser.ownerGlobal.document, "getElementById") + .returns(fakePanel); + + it("should call enableToolbarButton", async () => { + await Router.forceWNPanel(browser); + assert.calledOnce(FakeToolbarPanelHub.enableToolbarButton); + assert.calledOnce(browser.ownerGlobal.PanelUI.showSubView); + assert.calledWith(fakePanel.setAttribute, "noautohide", true); + }); + }); + + describe("_triggerHandler", () => { + it("should call #sendTriggerMessage with the correct trigger", () => { + const getter = sandbox.stub(); + getter.returns(false); + sandbox.stub(global.BrowserHandler, "kiosk").get(getter); + sinon.spy(Router, "sendTriggerMessage"); + const browser = {}; + const trigger = { id: "FAKE_TRIGGER", param: "some fake param" }; + Router._triggerHandler(browser, trigger); + assert.calledOnce(Router.sendTriggerMessage); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ + id: "FAKE_TRIGGER", + param: "some fake param", + }) + ); + }); + }); + + describe("_triggerHandler_kiosk", () => { + it("should not call #sendTriggerMessage", () => { + const getter = sandbox.stub(); + getter.returns(true); + sandbox.stub(global.BrowserHandler, "kiosk").get(getter); + sinon.spy(Router, "sendTriggerMessage"); + const browser = {}; + const trigger = { id: "FAKE_TRIGGER", param: "some fake param" }; + Router._triggerHandler(browser, trigger); + assert.notCalled(Router.sendTriggerMessage); + }); + }); + + describe("valid preview endpoint", () => { + it("should report an error if url protocol is not https", () => { + sandbox.stub(console, "error"); + + assert.equal(false, Router._validPreviewEndpoint("http://foo.com")); + assert.calledTwice(console.error); + }); + }); + + describe("impressions", () => { + describe("#addImpression for groups", () => { + it("should save an impression in each group-with-frequency in a message", async () => { + const fooMessageImpressions = [0]; + const aGroupImpressions = [0, 1, 2]; + const bGroupImpressions = [3, 4, 5]; + const cGroupImpressions = [6, 7, 8]; + + const message = { + id: "foo", + provider: "bar", + groups: ["a", "b", "c"], + }; + const groups = [ + { id: "a", frequency: { lifetime: 3 } }, + { id: "b", frequency: { lifetime: 4 } }, + { id: "c", frequency: { lifetime: 5 } }, + ]; + await Router.setState(state => { + // Add provider + const providers = [...state.providers]; + // Add fooMessageImpressions + // eslint-disable-next-line no-shadow + const messageImpressions = Object.assign( + {}, + state.messageImpressions + ); + let gImpressions = {}; + gImpressions.a = aGroupImpressions; + gImpressions.b = bGroupImpressions; + gImpressions.c = cGroupImpressions; + messageImpressions.foo = fooMessageImpressions; + return { + providers, + messageImpressions, + groups, + groupImpressions: gImpressions, + }; + }); + + await Router.addImpression(message); + + assert.deepEqual( + Router.state.groupImpressions.a, + [0, 1, 2, 0], + "a impressions" + ); + assert.deepEqual( + Router.state.groupImpressions.b, + [3, 4, 5, 0], + "b impressions" + ); + assert.deepEqual( + Router.state.groupImpressions.c, + [6, 7, 8, 0], + "c impressions" + ); + }); + }); + + describe("#isBelowFrequencyCaps", () => { + it("should call #_isBelowItemFrequencyCap for the message and for the provider with the correct impressions and arguments", async () => { + sinon.spy(Router, "_isBelowItemFrequencyCap"); + + const MAX_MESSAGE_LIFETIME_CAP = 100; // Defined in ASRouter + const fooMessageImpressions = [0, 1]; + const barGroupImpressions = [0, 1, 2]; + + const message = { + id: "foo", + provider: "bar", + groups: ["bar"], + frequency: { lifetime: 3 }, + }; + const groups = [{ id: "bar", frequency: { lifetime: 5 } }]; + + await Router.setState(state => { + // Add provider + const providers = [...state.providers]; + // Add fooMessageImpressions + // eslint-disable-next-line no-shadow + const messageImpressions = Object.assign( + {}, + state.messageImpressions + ); + let gImpressions = {}; + gImpressions.bar = barGroupImpressions; + messageImpressions.foo = fooMessageImpressions; + return { + providers, + messageImpressions, + groups, + groupImpressions: gImpressions, + }; + }); + + await Router.isBelowFrequencyCaps(message); + + assert.calledTwice(Router._isBelowItemFrequencyCap); + assert.calledWithExactly( + Router._isBelowItemFrequencyCap, + message, + fooMessageImpressions, + MAX_MESSAGE_LIFETIME_CAP + ); + assert.calledWithExactly( + Router._isBelowItemFrequencyCap, + groups[0], + barGroupImpressions + ); + }); + }); + + describe("#_isBelowItemFrequencyCap", () => { + it("should return false if the # of impressions exceeds the maxLifetimeCap", () => { + const item = { id: "foo", frequency: { lifetime: 5 } }; + const impressions = [0, 1]; + const maxLifetimeCap = 1; + const result = Router._isBelowItemFrequencyCap( + item, + impressions, + maxLifetimeCap + ); + assert.isFalse(result); + }); + + describe("lifetime frequency caps", () => { + it("should return true if .frequency is not defined on the item", () => { + const item = { id: "foo" }; + const impressions = [0, 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return true if there are no impressions", () => { + const item = { + id: "foo", + frequency: { + lifetime: 10, + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + }, + }; + const impressions = []; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return true if the # of impressions is less than .frequency.lifetime of the item", () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if the # of impressions is equal to .frequency.lifetime of the item", async () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1, 2]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return false if the # of impressions is greater than .frequency.lifetime of the item", async () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1, 2, 3]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + }); + + describe("custom frequency caps", () => { + it("should return true if impressions in the time period < the cap and total impressions < the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "foo", + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + lifetime: 3, + }, + }; + const impressions = [0, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if impressions in the time period > the cap and total impressions < the lifetime cap", () => { + clock.tick(200); + const item = { + id: "msg1", + frequency: { custom: [{ period: 100, cap: 2 }], lifetime: 3 }, + }; + const impressions = [0, 160, 161]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return false if impressions in one of the time periods > the cap and total impressions < the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 200); + const itemTrue = { + id: "msg2", + frequency: { custom: [{ period: 100, cap: 2 }] }, + }; + const itemFalse = { + id: "msg1", + frequency: { + custom: [ + { period: 100, cap: 2 }, + { period: ONE_DAY_IN_MS, cap: 3 }, + ], + }, + }; + const impressions = [ + 0, + ONE_DAY_IN_MS + 160, + ONE_DAY_IN_MS - 100, + ONE_DAY_IN_MS - 200, + ]; + assert.isTrue(Router._isBelowItemFrequencyCap(itemTrue, impressions)); + assert.isFalse( + Router._isBelowItemFrequencyCap(itemFalse, impressions) + ); + }); + it("should return false if impressions in the time period < the cap and total impressions > the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + lifetime: 3, + }, + }; + const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return true if daily impressions < the daily cap and there is no lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] }, + }; + const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if daily impressions > the daily cap and there is no lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] }, + }; + const impressions = [ + 0, + 1, + 2, + 3, + ONE_DAY_IN_MS + 1, + ONE_DAY_IN_MS + 2, + ONE_DAY_IN_MS + 3, + ]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + }); + }); + + describe("#getLongestPeriod", () => { + it("should return the period if there is only one definition", () => { + const message = { + id: "foo", + frequency: { custom: [{ period: 200, cap: 2 }] }, + }; + assert.equal(Router.getLongestPeriod(message), 200); + }); + it("should return the longest period if there are more than one definitions", () => { + const message = { + id: "foo", + frequency: { + custom: [ + { period: 1000, cap: 3 }, + { period: ONE_DAY_IN_MS, cap: 5 }, + { period: 100, cap: 2 }, + ], + }, + }; + assert.equal(Router.getLongestPeriod(message), ONE_DAY_IN_MS); + }); + it("should return null if there are is no .frequency", () => { + const message = { id: "foo" }; + assert.isNull(Router.getLongestPeriod(message)); + }); + it("should return null if there are is no .frequency.custom", () => { + const message = { id: "foo", frequency: { lifetime: 10 } }; + assert.isNull(Router.getLongestPeriod(message)); + }); + }); + + describe("cleanup on init", () => { + it("should clear messageImpressions for messages which do not exist in state.messages", async () => { + const messages = [{ id: "foo", frequency: { lifetime: 10 } }]; + messageImpressions = { foo: [0], bar: [0, 1] }; + // Impressions for "bar" should be removed since that id does not exist in messages + const result = { foo: [0] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions older than the period if no lifetime impression cap is included", async () => { + const CURRENT_TIME = ONE_DAY_IN_MS * 2; + clock.tick(CURRENT_TIME); + const messages = [ + { + id: "foo", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 5 }] }, + }, + ]; + messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] }; + // Only 0 and 1 are more than 24 hours before CURRENT_TIME + const result = { foo: [CURRENT_TIME - 10] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions older than the longest period if no lifetime impression cap is included", async () => { + const CURRENT_TIME = ONE_DAY_IN_MS * 2; + clock.tick(CURRENT_TIME); + const messages = [ + { + id: "foo", + frequency: { + custom: [ + { period: ONE_DAY_IN_MS, cap: 5 }, + { period: 100, cap: 2 }, + ], + }, + }, + ]; + messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] }; + // Only 0 and 1 are more than 24 hours before CURRENT_TIME + const result = { foo: [CURRENT_TIME - 10] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions if they are not properly formatted", async () => { + const messages = [{ id: "foo", frequency: { lifetime: 10 } }]; + // this is impromperly formatted since messageImpressions are supposed to be an array + messageImpressions = { foo: 0 }; + const result = {}; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should not clear messageImpressions for messages which do exist in state.messages", async () => { + const messages = [ + { id: "foo", frequency: { lifetime: 10 } }, + { id: "bar", frequency: { lifetime: 10 } }, + ]; + messageImpressions = { foo: [0], bar: [] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.notCalled(Router._storage.set); + assert.deepEqual(Router.state.messageImpressions, messageImpressions); + }); + }); + }); + + describe("#_onLocaleChanged", () => { + it("should call _maybeUpdateL10nAttachment in the handler", async () => { + sandbox.spy(Router, "_maybeUpdateL10nAttachment"); + await Router._onLocaleChanged(); + + assert.calledOnce(Router._maybeUpdateL10nAttachment); + }); + }); + + describe("#_maybeUpdateL10nAttachment", () => { + it("should update the l10n attachment if the locale was changed", async () => { + const getter = sandbox.stub(); + getter.onFirstCall().returns("en-US"); + getter.onSecondCall().returns("fr"); + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(getter); + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + sandbox.spy(Router, "setState"); + Router.loadMessagesFromAllProviders.resetHistory(); + + await Router._maybeUpdateL10nAttachment(); + + assert.calledWith(Router.setState, { + localeInUse: "fr", + providers: [ + { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + lastUpdated: undefined, + errors: [], + }, + ], + }); + assert.calledOnce(Router.loadMessagesFromAllProviders); + }); + it("should not update the l10n attachment if the provider doesn't need l10n attachment", async () => { + const getter = sandbox.stub(); + getter.onFirstCall().returns("en-US"); + getter.onSecondCall().returns("fr"); + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(getter); + const provider = { + id: "localProvider", + enabled: true, + type: "local", + }; + await createRouterAndInit([provider]); + Router.loadMessagesFromAllProviders.resetHistory(); + sandbox.spy(Router, "setState"); + + await Router._maybeUpdateL10nAttachment(); + + assert.notCalled(Router.setState); + assert.notCalled(Router.loadMessagesFromAllProviders); + }); + }); + describe("#observe", () => { + it("should reload l10n for CFRPageActions when the `USE_REMOTE_L10N_PREF` pref is changed", () => { + sandbox.spy(CFRPageActions, "reloadL10n"); + + Router.observe("", "", USE_REMOTE_L10N_PREF); + + assert.calledOnce(CFRPageActions.reloadL10n); + }); + it("should not react to other pref changes", () => { + sandbox.spy(CFRPageActions, "reloadL10n"); + + Router.observe("", "", "foo"); + + assert.notCalled(CFRPageActions.reloadL10n); + }); + }); + describe("#loadAllMessageGroups", () => { + it("should disable the group if the pref is false", async () => { + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(false); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: true, + type: "remote", + userPreferences: ["cfrAddons"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", false); + }); + it("should enable the group if at least one pref is true", async () => { + sandbox + .stub(ASRouterPreferences, "getUserPreference") + .withArgs("cfrAddons") + .returns(false) + .withArgs("cfrFeatures") + .returns(true); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: true, + type: "remote", + userPreferences: ["cfrAddons", "cfrFeatures"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", true); + }); + it("should be keep the group disabled if disabled is true", async () => { + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(true); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: false, + type: "remote", + userPreferences: ["cfrAddons"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", false); + }); + it("should keep local groups unchanged if provider doesn't require an update", async () => { + sandbox.stub(MessageLoaderUtils, "shouldProviderUpdate").returns(false); + sandbox.stub(MessageLoaderUtils, "_loadDataForProvider"); + await Router.setState({ + groups: [ + { + id: "cfr", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "cfr"); + + assert.ok(group); + assert.propertyVal(group, "enabled", true); + // Because it should not have updated + assert.notCalled(MessageLoaderUtils._loadDataForProvider); + }); + it("should update local groups on pref change (no RS update)", async () => { + sandbox.stub(MessageLoaderUtils, "shouldProviderUpdate").returns(false); + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(false); + await Router.setState({ + groups: [ + { + id: "cfr", + enabled: true, + collection: "collection", + type: "remote-settings", + userPreferences: ["cfrAddons"], + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "cfr"); + + assert.ok(group); + // Pref changed, updated the group state + assert.propertyVal(group, "enabled", false); + }); + }); + describe("unblockAll", () => { + it("Clears the message block list and returns the state value", async () => { + await Router.setState({ messageBlockList: ["one", "two", "three"] }); + assert.equal(Router.state.messageBlockList.length, 3); + const state = await Router.unblockAll(); + assert.equal(Router.state.messageBlockList.length, 0); + assert.equal(state.messageBlockList.length, 0); + }); + }); + describe("#loadMessagesForProvider", () => { + it("should fetch messages from the ExperimentAPI", async () => { + const args = { + type: "remote-experiments", + featureIds: ["spotlight"], + }; + + await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.calledOnce(global.NimbusFeatures.spotlight.getAllVariables); + assert.calledOnce(global.ExperimentAPI.getExperimentMetaData); + assert.calledWithExactly(global.ExperimentAPI.getExperimentMetaData, { + featureId: "spotlight", + }); + }); + it("should handle the case of no experiments in the ExperimentAPI", async () => { + const args = { + type: "remote-experiments", + featureIds: ["infobar"], + }; + + global.ExperimentAPI.getExperiment.returns(null); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 0); + }); + it("should normally load ExperimentAPI messages", async () => { + const args = { + type: "remote-experiments", + featureIds: ["infobar"], + }; + const enrollment = { + branch: { + slug: "branch01", + infobar: { + featureId: "infobar", + value: { id: "id01", trigger: { id: "openURL" } }, + }, + }, + }; + + global.NimbusFeatures.infobar.getAllVariables.returns( + enrollment.branch.infobar.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.returns([ + enrollment.branch, + { + slug: "control", + infobar: { + featureId: "infobar", + value: null, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 1); + }); + it("should skip disabled features and not load the messages", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + + global.NimbusFeatures.cfr.getAllVariables.returns(null); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 0); + }); + it("should fetch branches with trigger", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + const enrollment = { + slug: "exp01", + branch: { + slug: "branch01", + cfr: { + featureId: "cfr", + value: { id: "id01", trigger: { id: "openURL" } }, + }, + }, + }; + + global.NimbusFeatures.cfr.getAllVariables.returns( + enrollment.branch.cfr.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + slug: enrollment.slug, + active: true, + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.resolves([ + enrollment.branch, + { + slug: "branch02", + cfr: { + featureId: "cfr", + value: { id: "id02", trigger: { id: "openURL" } }, + }, + }, + { + // This branch should not be loaded as it doesn't have the trigger + slug: "branch03", + cfr: { + featureId: "cfr", + value: { id: "id03" }, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.equal(result.messages.length, 2); + assert.equal(result.messages[0].id, "id01"); + assert.equal(result.messages[1].id, "id02"); + assert.equal(result.messages[1].experimentSlug, "exp01"); + assert.equal(result.messages[1].branchSlug, "branch02"); + assert.deepEqual(result.messages[1].forReachEvent, { + sent: false, + group: "cfr", + }); + }); + it("should fetch branches with trigger even if enrolled branch is disabled", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + const enrollment = { + slug: "exp01", + branch: { + slug: "branch01", + cfr: { + featureId: "cfr", + value: {}, + }, + }, + }; + + // Nedds to match the `featureIds` value to return an enrollment + // for that feature + global.NimbusFeatures.cfr.getAllVariables.returns( + enrollment.branch.cfr.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + slug: enrollment.slug, + active: true, + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.resolves([ + enrollment.branch, + { + slug: "branch02", + cfr: { + featureId: "cfr", + value: { id: "id02", trigger: { id: "openURL" } }, + }, + }, + { + // This branch should not be loaded as it doesn't have the trigger + slug: "branch03", + cfr: { + featureId: "cfr", + value: { id: "id03" }, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.equal(result.messages.length, 1); + assert.equal(result.messages[0].id, "id02"); + assert.equal(result.messages[0].experimentSlug, "exp01"); + assert.equal(result.messages[0].branchSlug, "branch02"); + assert.deepEqual(result.messages[0].forReachEvent, { + sent: false, + group: "cfr", + }); + }); + }); + describe("#_remoteSettingsLoader", () => { + let provider; + let spy; + beforeEach(() => { + provider = { + id: "cfr", + collection: "cfr", + }; + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + spy = sandbox.spy(); + global.Downloader.prototype.downloadToDisk = spy; + }); + it("should be called with the expected dir path", async () => { + const dlSpy = sandbox.spy(global, "Downloader"); + + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.calledWith( + dlSpy, + "main", + "ms-language-packs", + "browser", + "newtab" + ); + }); + it("should allow fetch for known locales", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.calledOnce(spy); + }); + it("should fallback to 'en-US' for locale 'und' ", async () => { + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(() => "und"); + const getRecordSpy = sandbox.spy( + global.KintoHttpClient.prototype, + "getRecord" + ); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.ok(getRecordSpy.args[0][0].includes("en-US")); + assert.calledOnce(spy); + }); + it("should fallback to 'ja-JP-mac' for locale 'ja-JP-macos'", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "ja-JP-macos"); + const getRecordSpy = sandbox.spy( + global.KintoHttpClient.prototype, + "getRecord" + ); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.ok(getRecordSpy.args[0][0].includes("ja-JP-mac")); + assert.calledOnce(spy); + }); + it("should not allow fetch for unsupported locales", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "unkown"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.notCalled(spy); + }); + }); + describe("#resetMessageState", () => { + it("should reset all message impressions", async () => { + await Router.setState({ + messages: [{ id: "1" }, { id: "2" }], + }); + await Router.setState({ + messageImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] }, + }); // Add impressions for test messages + let impressions = Object.values(Router.state.messageImpressions); + assert.equal(impressions.filter(i => i.length).length, 2); // Both messages have impressions + + Router.resetMessageState(); + impressions = Object.values(Router.state.messageImpressions); + + assert.isEmpty(impressions.filter(i => i.length)); // Both messages now have zero impressions + assert.calledWithExactly(Router._storage.set, "messageImpressions", { + 1: [], + 2: [], + }); + }); + }); + describe("#resetGroupsState", () => { + it("should reset all group impressions", async () => { + await Router.setState({ + groups: [{ id: "1" }, { id: "2" }], + }); + await Router.setState({ + groupImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] }, + }); // Add impressions for test groups + let impressions = Object.values(Router.state.groupImpressions); + assert.equal(impressions.filter(i => i.length).length, 2); // Both groups have impressions + + Router.resetGroupsState(); + impressions = Object.values(Router.state.groupImpressions); + + assert.isEmpty(impressions.filter(i => i.length)); // Both groups now have zero impressions + assert.calledWithExactly(Router._storage.set, "groupImpressions", { + 1: [], + 2: [], + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterChild.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterChild.test.js new file mode 100644 index 0000000000..346f0e02f3 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouterChild.test.js @@ -0,0 +1,74 @@ +/*eslint max-nested-callbacks: ["error", 10]*/ +import { ASRouterChild } from "actors/ASRouterChild.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "common/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, + }); + }); + }); + }); + describe("use sends messages that need a response using sendQuery", () => { + it("NEWTAB_MESSAGE_REQUEST", () => { + const type = msg.NEWTAB_MESSAGE_REQUEST; + asRouterChild.asRouterMessage({ + type, + data: { + something: 1, + }, + }); + sandbox.assert.calledOnce(asRouterChild.sendQuery); + sandbox.assert.calledWith(asRouterChild.sendQuery, type, { + something: 1, + }); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterNewTabHook.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterNewTabHook.test.js new file mode 100644 index 0000000000..938c85d7de --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouterNewTabHook.test.js @@ -0,0 +1,153 @@ +/*eslint max-nested-callbacks: ["error", 10]*/ +import { ASRouterNewTabHook } from "lib/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(["snippets"]); + 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(["snippets"]); + 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(["snippets"]); + routerParams.updateAdminState({ messages: {} }); + instance.connect(messageHandler); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["snippets"]); + 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(["snippets"]); + 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/newtab/test/unit/asrouter/ASRouterParent.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterParent.test.js new file mode 100644 index 0000000000..1b494bbe0e --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouterParent.test.js @@ -0,0 +1,106 @@ +import { ASRouterParent } from "actors/ASRouterParent.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "common/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"); + }); + it("it messages all actors on BLOCK_MESSAGE_BY_ID messages", async () => { + const MESSAGE_ID = 1; + const result = await asRouterParent.receiveMessage({ + name: msg.BLOCK_MESSAGE_BY_ID, + data: { id: MESSAGE_ID, campaign: "message-campaign" }, + }); + assert.calledOnce(handleMessage); + // Check that we correctly pass the tabId + assert.calledWithExactly( + handleMessage, + sinon.match.any, + sinon.match.any, + { id: sinon.match.number, browser: sinon.match.any } + ); + assert.calledWithExactly( + ASRouterParent.tabs.messageAll, + "ClearMessages", + // When blocking an id the entire campaign is blocked + // and all other snippets become invalid + ["message-campaign"] + ); + assert.equal(result, "handle-message-result"); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterParentProcessMessageHandler.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterParentProcessMessageHandler.test.js new file mode 100644 index 0000000000..1f35ab875e --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouterParentProcessMessageHandler.test.js @@ -0,0 +1,428 @@ +import { ASRouterParentProcessMessageHandler } from "lib/ASRouterParentProcessMessageHandler.jsm"; +import { _ASRouter } from "lib/ASRouter.jsm"; +import { MESSAGE_TYPE_HASH as msg } from "common/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", + "addPreviewEndpoint", + "evaluateExpression", + "forceAttribution", + "forceWNPanel", + "closeWNPanel", + "forcePBWindow", + "resetGroupsState", + ].forEach(method => sandbox.stub(router, `${method}`).resolves()); + [ + "blockMessageById", + "loadMessagesFromAllProviders", + "sendNewTabMessage", + "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("NEWTAB_MESSAGE_REQUEST action", () => { + it("default calls sendNewTabMessage and returns state", async () => { + const result = await handler.handleMessage( + msg.NEWTAB_MESSAGE_REQUEST, + { + stuff: {}, + }, + { id: 100, browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.sendNewTabMessage); + assert.calledWith(config.router.sendNewTabMessage, { + stuff: {}, + tabId: 100, + browser: { ownerGlobal: {} }, + }); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("ADMIN_CONNECT_STATE action", () => { + it("with endpoint url calls addPreviewEndpoint, loadMessagesFromAllProviders, and returns state", async () => { + const result = await handler.handleMessage(msg.ADMIN_CONNECT_STATE, { + endpoint: { + url: "test", + }, + }); + assert.calledOnce(config.router.addPreviewEndpoint); + 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 }); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterPreferences.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterPreferences.test.js new file mode 100644 index 0000000000..3ad759d6b9 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouterPreferences.test.js @@ -0,0 +1,491 @@ +import { + _ASRouterPreferences, + ASRouterPreferences as ASRouterPreferencesSingleton, + TEST_PROVIDERS, +} from "lib/ASRouterPreferences.jsm"; +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 SNIPPETS_USER_PREF = "browser.newtabpage.activity-stream.feeds.snippets"; +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.feeds.snippets (user preference - snippets) + * 4. 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 = 6; + +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("#getUserPreference(providerId)", () => { + it("should return the user preference for snippets", () => { + boolPrefStub.withArgs(SNIPPETS_USER_PREF).returns(true); + assert.isTrue(ASRouterPreferences.getUserPreference("snippets")); + }); + }); + describe("#getAllUserPreferences", () => { + it("should return all user preferences", () => { + boolPrefStub.withArgs(SNIPPETS_USER_PREF).returns(true); + boolPrefStub.withArgs(CFR_USER_PREF_ADDONS).returns(false); + boolPrefStub.withArgs(CFR_USER_PREF_FEATURES).returns(true); + const result = ASRouterPreferences.getAllUserPreferences(); + assert.deepEqual(result, { + snippets: true, + 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("snippets", true); + assert.calledWith(setStub, SNIPPETS_USER_PREF, 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, SNIPPETS_USER_PREF); + 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/newtab/test/unit/asrouter/ASRouterTargeting.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterTargeting.test.js new file mode 100644 index 0000000000..a6e0eea3af --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouterTargeting.test.js @@ -0,0 +1,574 @@ +import { + ASRouterTargeting, + CachedTargetingGetter, + getSortedMessages, + QueryCache, +} from "lib/ASRouterTargeting.jsm"; +import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm"; +import { ASRouterPreferences } from "lib/ASRouterPreferences.jsm"; +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/newtab/test/unit/asrouter/ASRouterTriggerListeners.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterTriggerListeners.test.js new file mode 100644 index 0000000000..52a7785e05 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouterTriggerListeners.test.js @@ -0,0 +1,778 @@ +import { ASRouterTriggerListeners } from "lib/ASRouterTriggerListeners.jsm"; +import { ASRouterPreferences } from "lib/ASRouterPreferences.jsm"; +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 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); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/CFRMessageProvider.test.js b/browser/components/newtab/test/unit/asrouter/CFRMessageProvider.test.js new file mode 100644 index 0000000000..a5748d59ce --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/CFRMessageProvider.test.js @@ -0,0 +1,32 @@ +import { CFRMessageProvider } from "lib/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/newtab/test/unit/asrouter/CFRPageActions.test.js b/browser/components/newtab/test/unit/asrouter/CFRPageActions.test.js new file mode 100644 index 0000000000..744b9f148c --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/CFRPageActions.test.js @@ -0,0 +1,1252 @@ +/* eslint max-nested-callbacks: ["error", 100] */ + +import { CFRPageActions, PageAction } from "lib/CFRPageActions.jsm"; +import { FAKE_RECOMMENDATION } from "./constants"; +import { GlobalOverrider } from "test/unit/utils"; +import { CFRMessageProvider } from "lib/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; + + 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(); + + 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, + }); + 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("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 sandboxShowPopup = sinon.createSandbox(); + let fakePopUp = { + id: "fake_id", + template: "cfr_doorhanger", + content: { + skip_address_bar_notifier: true, + heading_text: "Fake Heading Text", + anchor_id: "fake_anchor_id", + }, + }; + beforeEach(() => { + const { id, content } = fakePopUp; + savedRec = { + id, + host: fakeHost, + content, + }; + CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec); + pageAction = new PageAction(window, dispatchStub); + + sandboxShowPopup.stub(window.document, "getElementById"); + sandboxShowPopup.stub(pageAction, "_renderPopup"); + globals.set({ + CustomizableUI: { + getWidget: sandboxShowPopup + .stub() + .withArgs(fakeAnchorId) + .returns({ areaType: "menu-panel" }), + }, + }); + }); + afterEach(() => { + sandboxShowPopup.restore(); + globals.restore(); + }); + + it("Should use default anchor_id if an alternate hasn't been provided", async () => { + await pageAction.showPopup(); + + assert.calledWith(window.document.getElementById, fakeAnchorId); + }); + + it("Should use alt_anchor_if if one has been provided AND the anchor_id has been removed", async () => { + let fakeAltAnchorId = "fake_alt_anchor_id"; + + fakePopUp.content.alt_anchor_id = fakeAltAnchorId; + await pageAction.showPopup(); + assert.calledWith(window.document.getElementById, fakeAltAnchorId); + }); + }); + + 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/newtab/test/unit/asrouter/MessageLoaderUtils.test.js b/browser/components/newtab/test/unit/asrouter/MessageLoaderUtils.test.js new file mode 100644 index 0000000000..d855f89d27 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/MessageLoaderUtils.test.js @@ -0,0 +1,459 @@ +import { MessageLoaderUtils } from "lib/ASRouter.jsm"; +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/newtab/test/unit/asrouter/ModalOverlay.test.jsx b/browser/components/newtab/test/unit/asrouter/ModalOverlay.test.jsx new file mode 100644 index 0000000000..889d26a9d3 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ModalOverlay.test.jsx @@ -0,0 +1,69 @@ +import { ModalOverlayWrapper } from "content-src/asrouter/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/newtab/test/unit/asrouter/RemoteL10n.test.js b/browser/components/newtab/test/unit/asrouter/RemoteL10n.test.js new file mode 100644 index 0000000000..34adfc88f1 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/RemoteL10n.test.js @@ -0,0 +1,217 @@ +import { RemoteL10n, _RemoteL10n } from "lib/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/newtab/test/unit/asrouter/RichText.test.jsx b/browser/components/newtab/test/unit/asrouter/RichText.test.jsx new file mode 100644 index 0000000000..07c2a4d4be --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/RichText.test.jsx @@ -0,0 +1,101 @@ +import { + convertLinks, + RichText, +} from "content-src/asrouter/components/RichText/RichText"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { + Localized, + LocalizationProvider, + ReactLocalization, +} from "@fluent/react"; +import { mount } from "enzyme"; +import React from "react"; + +function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; +} + +describe("convertLinks", () => { + let sandbox; + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + sandbox.restore(); + }); + it("should return an object with anchor elements", () => { + const cta = { + url: "https://foo.com", + metric: "foo", + }; + const stub = sandbox.stub(); + const result = convertLinks({ cta }, stub); + + assert.property(result, "cta"); + assert.propertyVal(result.cta, "type", "a"); + assert.propertyVal(result.cta.props, "href", cta.url); + assert.propertyVal(result.cta.props, "data-metric", cta.metric); + assert.propertyVal(result.cta.props, "onClick", stub); + }); + it("should return an anchor element without href", () => { + const cta = { + url: "https://foo.com", + metric: "foo", + action: "OPEN_MENU", + args: "appMenu", + entrypoint_name: "entrypoint_name", + entrypoint_value: "entrypoint_value", + }; + const stub = sandbox.stub(); + const result = convertLinks({ cta }, stub); + + assert.property(result, "cta"); + assert.propertyVal(result.cta, "type", "a"); + assert.propertyVal(result.cta.props, "href", false); + assert.propertyVal(result.cta.props, "data-metric", cta.metric); + assert.propertyVal(result.cta.props, "data-action", cta.action); + assert.propertyVal(result.cta.props, "data-args", cta.args); + assert.propertyVal( + result.cta.props, + "data-entrypoint_name", + cta.entrypoint_name + ); + assert.propertyVal( + result.cta.props, + "data-entrypoint_value", + cta.entrypoint_value + ); + assert.propertyVal(result.cta.props, "onClick", stub); + }); + it("should follow openNewWindow prop", () => { + const cta = { url: "https://foo.com" }; + const newWindow = convertLinks({ cta }, sandbox.stub(), false, true); + const sameWindow = convertLinks({ cta }, sandbox.stub(), false); + + assert.propertyVal(newWindow.cta.props, "target", "_blank"); + assert.propertyVal(sameWindow.cta.props, "target", ""); + }); + it("should allow for custom elements & styles", () => { + const wrapper = mount( + <RichText + customElements={{ em: <em style={{ color: "#f05" }} /> }} + text="<em>foo</em>" + localization_id="text" + />, + mockL10nWrapper({ text: "<em>foo</em>" }) + ); + + const localized = wrapper.find(Localized); + assert.propertyVal(localized.props().elems.em.props.style, "color", "#f05"); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/SnippetsTestMessageProvider.test.js b/browser/components/newtab/test/unit/asrouter/SnippetsTestMessageProvider.test.js new file mode 100644 index 0000000000..fc8fbe15ac --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/SnippetsTestMessageProvider.test.js @@ -0,0 +1,43 @@ +import EOYSnippetSchema from "../../../content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json"; +import SimpleBelowSearchSnippetSchema from "../../../content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json"; +import SimpleSnippetSchema from "../../../content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json"; +import { SnippetsTestMessageProvider } from "../../../lib/SnippetsTestMessageProvider.sys.mjs"; +import SubmitFormSnippetSchema from "../../../content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json"; +import SubmitFormScene2SnippetSchema from "../../../content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json"; + +const schemas = { + simple_snippet: SimpleSnippetSchema, + newsletter_snippet: SubmitFormSnippetSchema, + fxa_signup_snippet: SubmitFormSnippetSchema, + send_to_device_snippet: SubmitFormSnippetSchema, + send_to_device_scene2_snippet: SubmitFormScene2SnippetSchema, + eoy_snippet: EOYSnippetSchema, + simple_below_search_snippet: SimpleBelowSearchSnippetSchema, +}; + +describe("SnippetsTestMessageProvider", async () => { + let messages = await SnippetsTestMessageProvider.getMessages(); + + it("should return an array of messages", () => { + assert.isArray(messages); + }); + + it("should have a valid example of each schema", () => { + Object.keys(schemas).forEach(templateName => { + const example = messages.find( + message => message.template === templateName + ); + assert.ok(example, `has a ${templateName} example`); + }); + }); + + it("should have examples that are valid", () => { + messages.forEach(example => { + assert.jsonSchema( + example.content, + schemas[example.template], + `${example.id} should be valid` + ); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/TargetingDocs.test.js b/browser/components/newtab/test/unit/asrouter/TargetingDocs.test.js new file mode 100644 index 0000000000..eaef468488 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/TargetingDocs.test.js @@ -0,0 +1,88 @@ +import { ASRouterTargeting } from "lib/ASRouterTargeting.jsm"; +import docs from "content-src/asrouter/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.jsm +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/newtab/test/unit/asrouter/asrouter-content.test.jsx b/browser/components/newtab/test/unit/asrouter/asrouter-content.test.jsx new file mode 100644 index 0000000000..b581886111 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/asrouter-content.test.jsx @@ -0,0 +1,516 @@ +import { ASRouterUISurface } from "content-src/asrouter/asrouter-content"; +import { ASRouterUtils } from "content-src/asrouter/asrouter-utils"; +import { GlobalOverrider } from "test/unit/utils"; +import { FAKE_LOCAL_MESSAGES } from "./constants"; +import React from "react"; +import { mount } from "enzyme"; + +let [FAKE_MESSAGE] = FAKE_LOCAL_MESSAGES; +const FAKE_NEWSLETTER_SNIPPET = FAKE_LOCAL_MESSAGES.find( + msg => msg.id === "newsletter" +); +const FAKE_FXA_SNIPPET = FAKE_LOCAL_MESSAGES.find(msg => msg.id === "fxa"); +const FAKE_BELOW_SEARCH_SNIPPET = FAKE_LOCAL_MESSAGES.find( + msg => msg.id === "belowsearch" +); + +FAKE_MESSAGE = Object.assign({}, FAKE_MESSAGE, { provider: "fakeprovider" }); + +describe("ASRouterUtils", () => { + let globalOverrider; + let sandbox; + let globals; + beforeEach(() => { + globalOverrider = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + globals = { + ASRouterMessage: sandbox.stub(), + }; + globalOverrider.set(globals); + }); + afterEach(() => { + sandbox.restore(); + globalOverrider.restore(); + }); + it("should send a message with the right payload data", () => { + ASRouterUtils.sendTelemetry({ id: 1, event: "CLICK" }); + + assert.calledOnce(globals.ASRouterMessage); + assert.calledWith(globals.ASRouterMessage, { + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + data: { + id: 1, + event: "CLICK", + }, + }); + }); +}); + +describe("ASRouterUISurface", () => { + let wrapper; + let globalOverrider; + let sandbox; + let headerPortal; + let footerPortal; + let root; + let fakeDocument; + let globals; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + headerPortal = document.createElement("div"); + footerPortal = document.createElement("div"); + root = document.createElement("div"); + sandbox.stub(footerPortal, "querySelector").returns(footerPortal); + fakeDocument = { + location: { href: "" }, + _listeners: new Set(), + _visibilityState: "hidden", + head: { + appendChild(el) { + return el; + }, + }, + get visibilityState() { + return this._visibilityState; + }, + set visibilityState(value) { + if (this._visibilityState === value) { + return; + } + this._visibilityState = value; + this._listeners.forEach(l => l()); + }, + addEventListener(event, listener) { + this._listeners.add(listener); + }, + removeEventListener(event, listener) { + this._listeners.delete(listener); + }, + get body() { + return document.createElement("body"); + }, + getElementById(id) { + switch (id) { + case "header-asrouter-container": + return headerPortal; + case "root": + return root; + default: + return footerPortal; + } + }, + createElement(tag) { + return document.createElement(tag); + }, + }; + globals = { + ASRouterMessage: sandbox.stub().resolves(), + ASRouterAddParentListener: sandbox.stub(), + ASRouterRemoveParentListener: sandbox.stub(), + fetch: sandbox.stub().resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }), + }; + globalOverrider = new GlobalOverrider(); + globalOverrider.set(globals); + sandbox.stub(ASRouterUtils, "sendTelemetry"); + + wrapper = mount(<ASRouterUISurface document={fakeDocument} />); + }); + + afterEach(() => { + sandbox.restore(); + globalOverrider.restore(); + }); + + it("should render the component if a message id is defined", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + assert.isTrue(wrapper.exists()); + }); + + it("should pass in the correct form_method for newsletter snippets", () => { + wrapper.setState({ message: FAKE_NEWSLETTER_SNIPPET }); + + assert.isTrue(wrapper.find("SubmitFormSnippet").exists()); + assert.propertyVal( + wrapper.find("SubmitFormSnippet").props(), + "form_method", + "POST" + ); + }); + + it("should pass in the correct form_method for fxa snippets", () => { + wrapper.setState({ message: FAKE_FXA_SNIPPET }); + + assert.isTrue(wrapper.find("SubmitFormSnippet").exists()); + assert.propertyVal( + wrapper.find("SubmitFormSnippet").props(), + "form_method", + "GET" + ); + }); + + it("should render a preview banner if message provider is preview", () => { + wrapper.setState({ message: { ...FAKE_MESSAGE, provider: "preview" } }); + assert.isTrue(wrapper.find(".snippets-preview-banner").exists()); + }); + + it("should not render a preview banner if message provider is not preview", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + assert.isFalse(wrapper.find(".snippets-preview-banner").exists()); + }); + + it("should render a SimpleSnippet in the footer portal", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + assert.isTrue(footerPortal.childElementCount > 0); + assert.equal(headerPortal.childElementCount, 0); + }); + + it("should not render a SimpleBelowSearchSnippet in a portal", () => { + wrapper.setState({ message: FAKE_BELOW_SEARCH_SNIPPET }); + assert.equal(headerPortal.childElementCount, 0); + assert.equal(footerPortal.childElementCount, 0); + }); + + it("should dispatch an event to select the correct theme", () => { + const stub = sandbox.stub(window, "dispatchEvent"); + sandbox + .stub(ASRouterUtils, "getPreviewEndpoint") + .returns({ theme: "dark" }); + + wrapper = mount(<ASRouterUISurface document={fakeDocument} />); + + assert.calledOnce(stub); + assert.property(stub.firstCall.args[0].detail.data, "ntp_background"); + assert.property(stub.firstCall.args[0].detail.data, "ntp_text"); + assert.property(stub.firstCall.args[0].detail.data, "sidebar"); + assert.property(stub.firstCall.args[0].detail.data, "sidebar_text"); + }); + + it("should set `dir=rtl` on the page's <html> element if the dir param is set", () => { + assert.notPropertyVal(fakeDocument, "dir", "rtl"); + sandbox.stub(ASRouterUtils, "getPreviewEndpoint").returns({ dir: "rtl" }); + + wrapper = mount(<ASRouterUISurface document={fakeDocument} />); + assert.propertyVal(fakeDocument, "dir", "rtl"); + }); + + describe("snippets", () => { + it("should send correct event and source when snippet is blocked", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + + wrapper.find(".blockButton").simulate("click"); + assert.propertyVal( + ASRouterUtils.sendTelemetry.firstCall.args[0], + "event", + "BLOCK" + ); + assert.propertyVal( + ASRouterUtils.sendTelemetry.firstCall.args[0], + "source", + "NEWTAB_FOOTER_BAR" + ); + }); + + it("should not send telemetry when a preview snippet is blocked", () => { + wrapper.setState({ message: { ...FAKE_MESSAGE, provider: "preview" } }); + + wrapper.find(".blockButton").simulate("click"); + assert.notCalled(ASRouterUtils.sendTelemetry); + }); + }); + + describe("impressions", () => { + function simulateVisibilityChange(value) { + fakeDocument.visibilityState = value; + } + + it("should call blockById after CTA link is clicked", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + sandbox.stub(ASRouterUtils, "blockById").resolves(); + wrapper.instance().sendClick({ target: { dataset: { metric: "" } } }); + + assert.calledOnce(ASRouterUtils.blockById); + assert.calledWith(ASRouterUtils.blockById, FAKE_MESSAGE.id); + }); + + it("should executeAction if defined on the anchor", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + sandbox.spy(ASRouterUtils, "executeAction"); + wrapper.instance().sendClick({ + target: { dataset: { action: "OPEN_MENU", args: "appMenu" } }, + }); + + assert.calledOnce(ASRouterUtils.executeAction); + assert.calledWithExactly(ASRouterUtils.executeAction, { + type: "OPEN_MENU", + data: { args: "appMenu" }, + }); + }); + + it("should not call blockById if do_not_autoblock is true", () => { + wrapper.setState({ + message: { + ...FAKE_MESSAGE, + ...{ content: { ...FAKE_MESSAGE.content, do_not_autoblock: true } }, + }, + }); + sandbox.stub(ASRouterUtils, "blockById"); + wrapper.instance().sendClick({ target: { dataset: { metric: "" } } }); + + assert.notCalled(ASRouterUtils.blockById); + }); + + it("should not send an impression if no message exists", () => { + simulateVisibilityChange("visible"); + + assert.notCalled(ASRouterUtils.sendTelemetry); + }); + + it("should not send an impression if the page is not visible", () => { + simulateVisibilityChange("hidden"); + wrapper.setState({ message: FAKE_MESSAGE }); + + assert.notCalled(ASRouterUtils.sendTelemetry); + }); + + it("should not send an impression for a preview message", () => { + wrapper.setState({ message: { ...FAKE_MESSAGE, provider: "preview" } }); + assert.notCalled(ASRouterUtils.sendTelemetry); + + simulateVisibilityChange("visible"); + assert.notCalled(ASRouterUtils.sendTelemetry); + }); + + it("should send an impression ping when there is a message and the page becomes visible", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + assert.notCalled(ASRouterUtils.sendTelemetry); + + simulateVisibilityChange("visible"); + assert.calledOnce(ASRouterUtils.sendTelemetry); + }); + + it("should send the correct impression source", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + simulateVisibilityChange("visible"); + + assert.calledOnce(ASRouterUtils.sendTelemetry); + assert.propertyVal( + ASRouterUtils.sendTelemetry.firstCall.args[0], + "event", + "IMPRESSION" + ); + assert.propertyVal( + ASRouterUtils.sendTelemetry.firstCall.args[0], + "source", + "NEWTAB_FOOTER_BAR" + ); + }); + + it("should send an impression ping when the page is visible and a message gets loaded", () => { + simulateVisibilityChange("visible"); + wrapper.setState({ message: {} }); + assert.notCalled(ASRouterUtils.sendTelemetry); + + wrapper.setState({ message: FAKE_MESSAGE }); + assert.calledOnce(ASRouterUtils.sendTelemetry); + }); + + it("should send another impression ping if the message id changes", () => { + simulateVisibilityChange("visible"); + wrapper.setState({ message: FAKE_MESSAGE }); + assert.calledOnce(ASRouterUtils.sendTelemetry); + + wrapper.setState({ message: FAKE_LOCAL_MESSAGES[1] }); + assert.calledTwice(ASRouterUtils.sendTelemetry); + }); + + it("should not send another impression ping if the message id has not changed", () => { + simulateVisibilityChange("visible"); + wrapper.setState({ message: FAKE_MESSAGE }); + assert.calledOnce(ASRouterUtils.sendTelemetry); + + wrapper.setState({ somethingElse: 123 }); + assert.calledOnce(ASRouterUtils.sendTelemetry); + }); + + it("should not send another impression ping if the message is cleared", () => { + simulateVisibilityChange("visible"); + wrapper.setState({ message: FAKE_MESSAGE }); + assert.calledOnce(ASRouterUtils.sendTelemetry); + + wrapper.setState({ message: {} }); + assert.calledOnce(ASRouterUtils.sendTelemetry); + }); + + it("should call .sendTelemetry with the right message data", () => { + simulateVisibilityChange("visible"); + wrapper.setState({ message: FAKE_MESSAGE }); + + assert.calledOnce(ASRouterUtils.sendTelemetry); + const [payload] = ASRouterUtils.sendTelemetry.firstCall.args; + + assert.propertyVal(payload, "message_id", FAKE_MESSAGE.id); + assert.propertyVal(payload, "event", "IMPRESSION"); + assert.propertyVal( + payload, + "action", + `${FAKE_MESSAGE.provider}_user_event` + ); + assert.propertyVal(payload, "source", "NEWTAB_FOOTER_BAR"); + }); + + it("should construct a OPEN_ABOUT_PAGE action with attribution", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + const stub = sandbox.stub(ASRouterUtils, "executeAction"); + + wrapper.instance().sendClick({ + target: { + dataset: { + metric: "", + entrypoint_value: "snippet", + action: "OPEN_PREFERENCES_PAGE", + args: "home", + }, + }, + }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, { + type: "OPEN_PREFERENCES_PAGE", + data: { args: "home", entrypoint: "snippet" }, + }); + }); + + it("should construct a OPEN_ABOUT_PAGE action with attribution", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + const stub = sandbox.stub(ASRouterUtils, "executeAction"); + + wrapper.instance().sendClick({ + target: { + dataset: { + metric: "", + entrypoint_name: "entryPoint", + entrypoint_value: "snippet", + action: "OPEN_ABOUT_PAGE", + args: "logins", + }, + }, + }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, { + type: "OPEN_ABOUT_PAGE", + data: { args: "logins", entrypoint: "entryPoint=snippet" }, + }); + }); + }); + + describe(".fetchFlowParams", () => { + let dispatchStub; + const assertCalledWithURL = url => + assert.calledWith(globals.fetch, new URL(url).toString(), { + credentials: "omit", + }); + beforeEach(() => { + dispatchStub = sandbox.stub(); + wrapper = mount( + <ASRouterUISurface + dispatch={dispatchStub} + fxaEndpoint="https://accounts.firefox.com" + /> + ); + }); + it("should use the base url returned from the endpoint pref", async () => { + wrapper = mount( + <ASRouterUISurface + dispatch={dispatchStub} + fxaEndpoint="https://foo.com" + /> + ); + await wrapper.instance().fetchFlowParams(); + + assertCalledWithURL("https://foo.com/metrics-flow"); + }); + it("should add given search params to the URL", async () => { + const params = { foo: "1", bar: "2" }; + + await wrapper.instance().fetchFlowParams(params); + + assertCalledWithURL( + "https://accounts.firefox.com/metrics-flow?foo=1&bar=2" + ); + }); + it("should return flowId, flowBeginTime, deviceId on a 200 response", async () => { + const flowInfo = { flowId: "foo", flowBeginTime: 123, deviceId: "bar" }; + globals.fetch + .withArgs("https://accounts.firefox.com/metrics-flow") + .resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(flowInfo), + }); + + const result = await wrapper.instance().fetchFlowParams(); + assert.deepEqual(result, flowInfo); + }); + + describe(".onUserAction", () => { + it("if the action.type is ENABLE_FIREFOX_MONITOR, it should generate the right monitor URL given some flowParams", async () => { + const flowInfo = { flowId: "foo", flowBeginTime: 123, deviceId: "bar" }; + globals.fetch + .withArgs( + "https://accounts.firefox.com/metrics-flow?utm_term=avocado" + ) + .resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(flowInfo), + }); + + sandbox.spy(ASRouterUtils, "executeAction"); + + const msg = { + type: "ENABLE_FIREFOX_MONITOR", + data: { + args: { + url: "https://monitor.firefox.com?foo=bar", + flowRequestParams: { + utm_term: "avocado", + }, + }, + }, + }; + + await wrapper.instance().onUserAction(msg); + + assertCalledWithURL( + "https://accounts.firefox.com/metrics-flow?utm_term=avocado" + ); + assert.calledWith(ASRouterUtils.executeAction, { + type: "OPEN_URL", + data: { + args: new URL( + "https://monitor.firefox.com?foo=bar&deviceId=bar&flowId=foo&flowBeginTime=123" + ).toString(), + }, + }); + }); + it("if the action.type is not ENABLE_FIREFOX_MONITOR, it should just call ASRouterUtils.executeAction", async () => { + const msg = { + type: "FOO", + data: { + args: "bar", + }, + }; + sandbox.spy(ASRouterUtils, "executeAction"); + await wrapper.instance().onUserAction(msg); + assert.calledWith(ASRouterUtils.executeAction, msg); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/asrouter-utils.test.js b/browser/components/newtab/test/unit/asrouter/asrouter-utils.test.js new file mode 100644 index 0000000000..1027b8ddab --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/asrouter-utils.test.js @@ -0,0 +1,100 @@ +import { ASRouterUtils } from "content-src/asrouter/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" })); + }); + }); + 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("sendTelemetry", () => { + it("default", () => { + ASRouterUtils.sendTelemetry({ foo: "bar" }); + assert.calledOnce(globals.ASRouterMessage); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/compatibility-reference/fx57-compat.test.js b/browser/components/newtab/test/unit/asrouter/compatibility-reference/fx57-compat.test.js new file mode 100644 index 0000000000..335318d9c6 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/compatibility-reference/fx57-compat.test.js @@ -0,0 +1,26 @@ +import EOYSnippetSchema from "content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json"; +import { expectedValues } from "./snippets-fx57"; +import SimpleSnippetSchema from "content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json"; +import SubmitFormSchema from "content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json"; + +export const SnippetsSchemas = { + eoy_snippet: EOYSnippetSchema, + simple_snippet: SimpleSnippetSchema, + newsletter_snippet: SubmitFormSchema, + fxa_signup_snippet: SubmitFormSchema, + send_to_device_snippet: SubmitFormSchema, +}; + +describe("Firefox 57 compatibility test", () => { + Object.keys(expectedValues).forEach(template => { + describe(template, () => { + const schema = SnippetsSchemas[template]; + it(`should have a schema for ${template}`, () => { + assert.ok(schema); + }); + it(`should validate with the schema for ${template}`, () => { + assert.jsonSchema(expectedValues[template], schema); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/compatibility-reference/snippets-fx57.js b/browser/components/newtab/test/unit/asrouter/compatibility-reference/snippets-fx57.js new file mode 100644 index 0000000000..e63256315b --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/compatibility-reference/snippets-fx57.js @@ -0,0 +1,125 @@ +/** + * IMPORTANT NOTE!!! + * + * Please DO NOT introduce breaking changes file without contacting snippets endpoint engineers + * and changing the schema version to reflect a breaking change. + * + */ + +const DATA_URI_IMAGE = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + +export const expectedValues = { + // Simple Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/simple-snippet.html) + simple_snippet: { + icon: DATA_URI_IMAGE, + button_label: "Click me", + button_url: "https://mozilla.org", + button_background_color: "#FF0000", + button_color: "#FFFFFF", + text: "Hello world", + title: "Hi!", + title_icon: DATA_URI_IMAGE, + tall: true, + }, + + // FXA Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/fxa.html) + fxa_signup_snippet: { + scene1_icon: DATA_URI_IMAGE, + scene1_button_label: "Click me", + scene1_button_background_color: "#FF0000", + scene1_button_color: "#FFFFFF", + scene1_text: "Hello <em>world</em>", + scene1_title: "Hi!", + scene1_title_icon: DATA_URI_IMAGE, + + scene2_text: "Second scene", + scene2_title: "Second scene title", + scene2_email_placeholder_text: "Email here", + scene2_button_label: "Sign Me Up", + scene2_dismiss_button_text: "Dismiss", + + utm_campaign: "snippets123", + utm_term: "123term", + }, + + // Send To Device Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/send-to-device.html) + send_to_device_snippet: { + include_sms: true, + locale: "de", + country: "DE", + message_id_sms: "foo", + message_id_email: "foo", + scene1_button_background_color: "#FF0000", + scene1_button_color: "#FFFFFF", + scene1_button_label: "Click me", + scene1_icon: DATA_URI_IMAGE, + scene1_text: "Hello world", + scene1_title: "Hi!", + scene1_title_icon: DATA_URI_IMAGE, + + scene2_button_label: "Sign Me Up", + scene2_disclaimer_html: "Hello <em>world</em>", + scene2_dismiss_button_text: "Dismiss", + scene2_icon: DATA_URI_IMAGE, + scene2_input_placeholder: "Email here", + + scene2_text: "Second scene", + scene2_title: "Second scene title", + + error_text: "error", + success_text: "all good", + success_title: "Ok!", + }, + + // Newsletter Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/newsletter-subscribe.html) + newsletter_snippet: { + scene1_icon: DATA_URI_IMAGE, + scene1_button_label: "Click me", + scene1_button_background_color: "#FF0000", + scene1_button_color: "#FFFFFF", + scene1_text: "Hello world", + scene1_title: "Hi!", + scene1_title_icon: DATA_URI_IMAGE, + + scene2_text: "Second scene", + scene2_title: "Second scene title", + scene2_newsletter: "foo", + scene2_email_placeholder_text: "Email here", + scene2_button_label: "Sign Me Up", + scene2_privacy_html: "Hello <em>world</em>", + scene2_dismiss_button_text: "Dismiss", + + locale: "de", + + error_text: "error", + success_text: "all good", + }, + + // EOY Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/mofo-eoy-2017.html) + eoy_snippet: { + block_button_text: "Block", + + donation_form_url: "https://donate.mozilla.org/", + text: "Big corporations want to restrict how we access the web. Fake news is making it harder for us to find the truth. Online bullies are silencing inspired voices. The not-for-profit Mozilla Foundation fights for a healthy internet with programs like our Tech Policy Fellowships and Internet Health Report; will you donate today?", + icon: DATA_URI_IMAGE, + button_label: "Donate", + monthly_checkbox_label_text: "Make my donation monthly", + button_background_color: "#0060DF", + button_color: "#FFFFFF", + background_color: "#FFFFFF", + text_color: "#000000", + highlight_color: "#FFE900", + + locale: "en-US", + currency_code: "usd", + + donation_amount_first: 50, + donation_amount_second: 25, + donation_amount_third: 10, + donation_amount_fourth: 3, + selected_button: "donation_amount_second", + + test: "bold", + }, +}; diff --git a/browser/components/newtab/test/unit/asrouter/constants.js b/browser/components/newtab/test/unit/asrouter/constants.js new file mode 100644 index 0000000000..392ca66ae3 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/constants.js @@ -0,0 +1,137 @@ +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", + provider: "snippets", + template: "simple_snippet", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "foo1", + template: "simple_snippet", + provider: "snippets", + bundled: 2, + order: 1, + content: { title: "Foo1", body: "Foo123-1" }, + }, + { + id: "foo2", + template: "simple_snippet", + provider: "snippets", + 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", + provider: "snippets", + template: "newsletter_snippet", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "fxa", + provider: "snippets", + template: "fxa_signup_snippet", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "belowsearch", + provider: "snippets", + template: "simple_below_search_snippet", + 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: "simple_snippet", + 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/newtab/test/unit/asrouter/template-utils.test.js b/browser/components/newtab/test/unit/asrouter/template-utils.test.js new file mode 100644 index 0000000000..e5f4b5ef4d --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/template-utils.test.js @@ -0,0 +1,31 @@ +import { safeURI } from "content-src/asrouter/template-utils"; + +describe("safeURI", () => { + let warnStub; + beforeEach(() => { + warnStub = sinon.stub(console, "warn"); + }); + afterEach(() => { + warnStub.restore(); + }); + it("should allow http: URIs", () => { + assert.equal(safeURI("http://foo.com"), "http://foo.com"); + }); + it("should allow https: URIs", () => { + assert.equal(safeURI("https://foo.com"), "https://foo.com"); + }); + it("should allow data URIs", () => { + assert.equal( + safeURI("data:image/png;base64,iVBO"), + "data:image/png;base64,iVBO" + ); + }); + it("should not allow javascript: URIs", () => { + assert.equal(safeURI("javascript:foo()"), ""); // eslint-disable-line no-script-url + assert.calledOnce(warnStub); + }); + it("should not warn if the URL is falsey ", () => { + assert.equal(safeURI(), ""); + assert.notCalled(warnStub); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/EOYSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/EOYSnippet.test.jsx new file mode 100644 index 0000000000..bd4ab00468 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/EOYSnippet.test.jsx @@ -0,0 +1,213 @@ +import { EOYSnippet } from "content-src/asrouter/templates/EOYSnippet/EOYSnippet"; +import { GlobalOverrider } from "test/unit/utils"; +import { mount } from "enzyme"; +import React from "react"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import schema from "content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json"; + +const DEFAULT_CONTENT = { + text: "foo", + donation_amount_first: 50, + donation_amount_second: 25, + donation_amount_third: 10, + donation_amount_fourth: 5, + donation_form_url: "https://submit.form", + button_label: "Donate", +}; + +describe("EOYSnippet", () => { + let sandbox; + let wrapper; + + function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; + } + + /** + * mountAndCheckProps - Mounts a EOYSnippet with DEFAULT_CONTENT extended with any props + * passed in the content param and validates props against the schema. + * @param {obj} content Object containing custom message content (e.g. {text, icon, title}) + * @returns enzyme wrapper for EOYSnippet + */ + function mountAndCheckProps(content = {}, provider = "test-provider") { + const props = { + content: Object.assign({}, DEFAULT_CONTENT, content), + provider, + onAction: sandbox.stub(), + onBlock: sandbox.stub(), + sendClick: sandbox.stub(), + }; + const comp = mount( + <EOYSnippet {...props} />, + mockL10nWrapper(props.content) + ); + // Check schema with the final props the component receives (including defaults) + assert.jsonSchema(comp.children().get(0).props.content, schema); + return comp; + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + wrapper = mountAndCheckProps(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should have the correct defaults", () => { + wrapper = mountAndCheckProps(); + // SendToDeviceSnippet is a wrapper around SubmitFormSnippet + const { props } = wrapper.children().get(0); + + const defaultProperties = Object.keys(schema.properties).filter( + prop => schema.properties[prop].default + ); + assert.lengthOf(defaultProperties, 4); + defaultProperties.forEach(prop => + assert.propertyVal(props.content, prop, schema.properties[prop].default) + ); + }); + + it("should render 4 donation options", () => { + assert.lengthOf(wrapper.find("input[type='radio']"), 4); + }); + + it("should have a data-metric field", () => { + assert.ok(wrapper.find("form[data-metric='EOYSnippetForm']").exists()); + }); + + it("should select the second donation option", () => { + wrapper = mountAndCheckProps({ selected_button: "donation_amount_second" }); + + assert.propertyVal( + wrapper.find("input[type='radio']").get(1).props, + "defaultChecked", + true + ); + }); + + it("should set frequency value to monthly", () => { + const form = wrapper.find("form").instance(); + assert.equal(form.querySelector("[name='frequency']").value, "single"); + + form.querySelector("#monthly-checkbox").checked = true; + wrapper.find("form").simulate("submit"); + + assert.equal(form.querySelector("[name='frequency']").value, "monthly"); + }); + + it("should block after submitting the form", () => { + const onBlockStub = sandbox.stub(); + wrapper.setProps({ onBlock: onBlockStub }); + + wrapper.find("form").simulate("submit"); + + assert.calledOnce(onBlockStub); + }); + + it("should not block if do_not_autoblock is true", () => { + const onBlockStub = sandbox.stub(); + wrapper = mountAndCheckProps({ do_not_autoblock: true }); + wrapper.setProps({ onBlock: onBlockStub }); + + wrapper.find("form").simulate("submit"); + + assert.notCalled(onBlockStub); + }); + + it("should report form submissions", () => { + wrapper = mountAndCheckProps(); + const { sendClick } = wrapper.props(); + + wrapper.find("form").simulate("submit"); + + assert.calledOnce(sendClick); + assert.equal( + sendClick.firstCall.args[0].target.dataset.metric, + "EOYSnippetForm" + ); + }); + + it("it should preserve URL GET params as hidden inputs", () => { + wrapper = mountAndCheckProps({ + donation_form_url: + "https://donate.mozilla.org/pl/?utm_source=desktop-snippet&utm_medium=snippet&utm_campaign=donate&utm_term=7556", + }); + + const hiddenInputs = wrapper.find("input[type='hidden']"); + + assert.propertyVal( + hiddenInputs.find("[name='utm_source']").props(), + "value", + "desktop-snippet" + ); + assert.propertyVal( + hiddenInputs.find("[name='amp;utm_medium']").props(), + "value", + "snippet" + ); + assert.propertyVal( + hiddenInputs.find("[name='amp;utm_campaign']").props(), + "value", + "donate" + ); + assert.propertyVal( + hiddenInputs.find("[name='amp;utm_term']").props(), + "value", + "7556" + ); + }); + + describe("locale", () => { + let stub; + let globals; + beforeEach(() => { + globals = new GlobalOverrider(); + stub = sandbox.stub().returns({ format: () => {} }); + + globals = new GlobalOverrider(); + globals.set({ Intl: { NumberFormat: stub } }); + }); + afterEach(() => { + globals.restore(); + }); + + it("should use content.locale for Intl", () => { + // triggers component rendering and calls the function we're testing + wrapper.setProps({ + content: { + locale: "locale-foo", + donation_form_url: DEFAULT_CONTENT.donation_form_url, + }, + }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, "locale-foo", sinon.match.object); + }); + + it("should use navigator.language as locale fallback", () => { + // triggers component rendering and calls the function we're testing + wrapper.setProps({ + content: { + locale: null, + donation_form_url: DEFAULT_CONTENT.donation_form_url, + }, + }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, navigator.language, sinon.match.object); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/ExtensionDoorhanger.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/ExtensionDoorhanger.test.jsx new file mode 100644 index 0000000000..bef14c6982 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/ExtensionDoorhanger.test.jsx @@ -0,0 +1,112 @@ +import { CFRMessageProvider } from "lib/CFRMessageProvider.sys.mjs"; +import CFRDoorhangerSchema from "content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json"; +import CFRChicletSchema from "content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json"; +import InfoBarSchema from "content-src/asrouter/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/newtab/test/unit/asrouter/templates/FXASignupSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/FXASignupSnippet.test.jsx new file mode 100644 index 0000000000..56828d266b --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/FXASignupSnippet.test.jsx @@ -0,0 +1,106 @@ +import { FXASignupSnippet } from "content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet"; +import { mount } from "enzyme"; +import React from "react"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import schema from "content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json"; +import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.sys.mjs"; + +describe("FXASignupSnippet", () => { + let DEFAULT_CONTENT; + let sandbox; + + function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; + } + + function mountAndCheckProps(content = {}) { + const props = { + id: "foo123", + content: Object.assign( + { utm_campaign: "foo", utm_term: "bar" }, + DEFAULT_CONTENT, + content + ), + onBlock() {}, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + }; + const comp = mount( + <FXASignupSnippet {...props} />, + mockL10nWrapper(props.content) + ); + // Check schema with the final props the component receives (including defaults) + assert.jsonSchema(comp.children().get(0).props.content, schema); + return comp; + } + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + DEFAULT_CONTENT = (await SnippetsTestMessageProvider.getMessages()).find( + msg => msg.template === "fxa_signup_snippet" + ).content; + }); + afterEach(() => { + sandbox.restore(); + }); + + it("should have the correct defaults", () => { + const defaults = { + id: "foo123", + onBlock() {}, + content: {}, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + }; + const wrapper = mount( + <FXASignupSnippet {...defaults} />, + mockL10nWrapper(DEFAULT_CONTENT) + ); + // FXASignupSnippet is a wrapper around SubmitFormSnippet + const { props } = wrapper.children().get(0); + + const defaultProperties = Object.keys(schema.properties).filter( + prop => schema.properties[prop].default + ); + assert.lengthOf(defaultProperties, 5); + defaultProperties.forEach(prop => + assert.propertyVal(props.content, prop, schema.properties[prop].default) + ); + + const defaultHiddenProperties = Object.keys( + schema.properties.hidden_inputs.properties + ).filter(prop => schema.properties.hidden_inputs.properties[prop].default); + assert.lengthOf(defaultHiddenProperties, 0); + }); + + it("should have a form_action", () => { + const wrapper = mountAndCheckProps(); + + assert.propertyVal( + wrapper.children().get(0).props, + "form_action", + "https://accounts.firefox.com/" + ); + }); + + it("should navigate to scene2", () => { + const wrapper = mountAndCheckProps({}); + + wrapper.find(".ASRouterButton").simulate("click"); + + assert.lengthOf(wrapper.find(".mainInput"), 1); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/NewsletterSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/NewsletterSnippet.test.jsx new file mode 100644 index 0000000000..cb80abdae0 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/NewsletterSnippet.test.jsx @@ -0,0 +1,108 @@ +import { mount } from "enzyme"; +import { NewsletterSnippet } from "content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet"; +import React from "react"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import schema from "content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json"; +import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.sys.mjs"; + +describe("NewsletterSnippet", () => { + let sandbox; + let DEFAULT_CONTENT; + + function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; + } + + function mountAndCheckProps(content = {}) { + const props = { + id: "foo123", + content: Object.assign({}, DEFAULT_CONTENT, content), + onBlock() {}, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + }; + const comp = mount( + <NewsletterSnippet {...props} />, + mockL10nWrapper(props.content) + ); + // Check schema with the final props the component receives (including defaults) + assert.jsonSchema(comp.children().get(0).props.content, schema); + return comp; + } + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + DEFAULT_CONTENT = (await SnippetsTestMessageProvider.getMessages()).find( + msg => msg.template === "newsletter_snippet" + ).content; + }); + afterEach(() => { + sandbox.restore(); + }); + + describe("schema test", () => { + it("should validate the schema and defaults", () => { + const wrapper = mountAndCheckProps(); + wrapper.find(".ASRouterButton").simulate("click"); + assert.equal(wrapper.find(".mainInput").instance().type, "email"); + }); + + it("should have all of the default fields", () => { + const defaults = { + id: "foo123", + content: {}, + onBlock() {}, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + }; + const wrapper = mount( + <NewsletterSnippet {...defaults} />, + mockL10nWrapper(DEFAULT_CONTENT) + ); + // NewsletterSnippet is a wrapper around SubmitFormSnippet + const { props } = wrapper.children().get(0); + + // the `locale` properties gets used as part of hidden_fields so we + // check for it separately + const properties = { ...schema.properties }; + const { locale } = properties; + delete properties.locale; + + const defaultProperties = Object.keys(properties).filter( + prop => properties[prop].default + ); + assert.lengthOf(defaultProperties, 6); + defaultProperties.forEach(prop => + assert.propertyVal(props.content, prop, properties[prop].default) + ); + + const defaultHiddenProperties = Object.keys( + schema.properties.hidden_inputs.properties + ).filter( + prop => schema.properties.hidden_inputs.properties[prop].default + ); + assert.lengthOf(defaultHiddenProperties, 1); + defaultHiddenProperties.forEach(prop => + assert.propertyVal( + props.content.hidden_inputs, + prop, + schema.properties.hidden_inputs.properties[prop].default + ) + ); + assert.propertyVal(props.content.hidden_inputs, "lang", locale.default); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/SendToDeviceSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/SendToDeviceSnippet.test.jsx new file mode 100644 index 0000000000..3c60967643 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/SendToDeviceSnippet.test.jsx @@ -0,0 +1,277 @@ +import { mount } from "enzyme"; +import React from "react"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import schema from "content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json"; +import { + SendToDeviceSnippet, + SendToDeviceScene2Snippet, +} from "content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet"; +import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.sys.mjs"; + +async function testBodyContains(body, key, value) { + const regex = new RegExp( + `Content-Disposition: form-data; name="${key}"${value}` + ); + const match = regex.exec(body); + return match; +} + +/** + * Simulates opening the second panel (form view), filling in the input, and submitting + * @param {EnzymeWrapper} wrapper A SendToDevice wrapper + * @param {string} value Email or phone number + * @param {function?} setCustomValidity setCustomValidity stub + */ +function openFormAndSetValue(wrapper, value, setCustomValidity = () => {}) { + // expand + wrapper.find(".ASRouterButton").simulate("click"); + // Fill in email + const input = wrapper.find(".mainInput"); + input.instance().value = value; + input.simulate("change", { target: { value, setCustomValidity } }); + wrapper.find("form").simulate("submit"); +} + +describe("SendToDeviceSnippet", () => { + let sandbox; + let fetchStub; + let jsonResponse; + let DEFAULT_CONTENT; + let DEFAULT_SCENE2_CONTENT; + + function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; + } + + function mountAndCheckProps(content = {}) { + const props = { + id: "foo123", + content: Object.assign({}, DEFAULT_CONTENT, content), + onBlock() {}, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + }; + const comp = mount( + <SendToDeviceSnippet {...props} />, + mockL10nWrapper(props.content) + ); + // Check schema with the final props the component receives (including defaults) + assert.jsonSchema(comp.children().get(0).props.content, schema); + return comp; + } + + beforeEach(async () => { + DEFAULT_CONTENT = (await SnippetsTestMessageProvider.getMessages()).find( + msg => msg.template === "send_to_device_snippet" + ).content; + DEFAULT_SCENE2_CONTENT = ( + await SnippetsTestMessageProvider.getMessages() + ).find(msg => msg.template === "send_to_device_scene2_snippet").content; + sandbox = sinon.createSandbox(); + jsonResponse = { status: "ok" }; + fetchStub = sandbox + .stub(global, "fetch") + .returns(Promise.resolve({ json: () => Promise.resolve(jsonResponse) })); + }); + afterEach(() => { + sandbox.restore(); + }); + + it("should have the correct defaults", () => { + const defaults = { + id: "foo123", + onBlock() {}, + content: {}, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + form_method: "POST", + }; + const wrapper = mount( + <SendToDeviceSnippet {...defaults} />, + mockL10nWrapper(DEFAULT_CONTENT) + ); + // SendToDeviceSnippet is a wrapper around SubmitFormSnippet + const { props } = wrapper.children().get(0); + + const defaultProperties = Object.keys(schema.properties).filter( + prop => schema.properties[prop].default + ); + assert.lengthOf(defaultProperties, 7); + defaultProperties.forEach(prop => + assert.propertyVal(props.content, prop, schema.properties[prop].default) + ); + + const defaultHiddenProperties = Object.keys( + schema.properties.hidden_inputs.properties + ).filter(prop => schema.properties.hidden_inputs.properties[prop].default); + assert.lengthOf(defaultHiddenProperties, 0); + }); + + describe("form input", () => { + it("should set the input type to text if content.include_sms is true", () => { + const wrapper = mountAndCheckProps({ include_sms: true }); + wrapper.find(".ASRouterButton").simulate("click"); + assert.equal(wrapper.find(".mainInput").instance().type, "text"); + }); + it("should set the input type to email if content.include_sms is false", () => { + const wrapper = mountAndCheckProps({ include_sms: false }); + wrapper.find(".ASRouterButton").simulate("click"); + assert.equal(wrapper.find(".mainInput").instance().type, "email"); + }); + it("should validate the input with isEmailOrPhoneNumber if include_sms is true", () => { + const wrapper = mountAndCheckProps({ include_sms: true }); + const setCustomValidity = sandbox.stub(); + openFormAndSetValue(wrapper, "foo", setCustomValidity); + assert.calledWith( + setCustomValidity, + "Must be an email or a phone number." + ); + }); + it("should not custom validate the input if include_sms is false", () => { + const wrapper = mountAndCheckProps({ include_sms: false }); + const setCustomValidity = sandbox.stub(); + openFormAndSetValue(wrapper, "foo", setCustomValidity); + assert.notCalled(setCustomValidity); + }); + }); + + describe("submitting", () => { + it("should send the right information to basket.mozilla.org/news/subscribe for an email", async () => { + const wrapper = mountAndCheckProps({ + locale: "fr-CA", + include_sms: true, + message_id_email: "foo", + }); + + openFormAndSetValue(wrapper, "foo@bar.com"); + wrapper.find("form").simulate("submit"); + + assert.calledOnce(fetchStub); + const [request] = fetchStub.firstCall.args; + + assert.equal(request.url, "https://basket.mozilla.org/news/subscribe/"); + const body = await request.text(); + assert.ok(testBodyContains(body, "email", "foo@bar.com"), "has email"); + assert.ok(testBodyContains(body, "lang", "fr-CA"), "has lang"); + assert.ok( + testBodyContains(body, "newsletters", "foo"), + "has newsletters" + ); + assert.ok( + testBodyContains(body, "source_url", "foo"), + "https%3A%2F%2Fsnippets.mozilla.com%2Fshow%2Ffoo123" + ); + }); + it("should send the right information for an sms", async () => { + const wrapper = mountAndCheckProps({ + locale: "fr-CA", + include_sms: true, + message_id_sms: "foo", + country: "CA", + }); + + openFormAndSetValue(wrapper, "5371283767"); + wrapper.find("form").simulate("submit"); + + assert.calledOnce(fetchStub); + const [request] = fetchStub.firstCall.args; + + assert.equal( + request.url, + "https://basket.mozilla.org/news/subscribe_sms/" + ); + const body = await request.text(); + assert.ok( + testBodyContains(body, "mobile_number", "5371283767"), + "has number" + ); + assert.ok(testBodyContains(body, "lang", "fr-CA"), "has lang"); + assert.ok(testBodyContains(body, "country", "CA"), "CA"); + assert.ok(testBodyContains(body, "msg_name", "foo"), "has msg_name"); + }); + }); + + describe("SendToDeviceScene2Snippet", () => { + function mountWithProps(content = {}) { + const props = { + id: "foo123", + content: Object.assign({}, DEFAULT_SCENE2_CONTENT, content), + onBlock() {}, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + }; + return mount( + <SendToDeviceScene2Snippet {...props} />, + mockL10nWrapper(props.content) + ); + } + + it("should render scene 2", () => { + const wrapper = mountWithProps(); + + assert.lengthOf(wrapper.find(".scene2Icon"), 1, "Found scene 2 icon"); + assert.lengthOf( + wrapper.find(".scene2Title"), + 0, + "Should not have a large header" + ); + }); + it("should have block button", () => { + const wrapper = mountWithProps(); + + assert.lengthOf( + wrapper.find(".blockButton"), + 1, + "Found the block button" + ); + }); + it("should render title text", () => { + const wrapper = mountWithProps(); + + assert.lengthOf( + wrapper.find(".section-title-text"), + 1, + "Found the section title" + ); + assert.lengthOf( + wrapper.find(".section-title .icon"), + 2, // light and dark theme + "Found scene 2 title" + ); + }); + it("should wrap the header in an anchor tag if condition is defined", () => { + const sectionTitleProp = { + section_title_url: "https://support.mozilla.org", + }; + let wrapper = mountWithProps(sectionTitleProp); + + const element = wrapper.find(".section-title a"); + assert.lengthOf(element, 1); + }); + it("should render a header without an anchor", () => { + const sectionTitleProp = { + section_title_url: undefined, + }; + let wrapper = mountWithProps(sectionTitleProp); + assert.lengthOf(wrapper.find(".section-title a"), 0); + assert.equal( + wrapper.find(".section-title").instance().innerText, + DEFAULT_SCENE2_CONTENT.section_title_text + ); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx new file mode 100644 index 0000000000..df9e544a54 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx @@ -0,0 +1,81 @@ +import { mount } from "enzyme"; +import React from "react"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import schema from "content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json"; +import { SimpleBelowSearchSnippet } from "content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx"; + +const DEFAULT_CONTENT = { text: "foo" }; + +describe("SimpleBelowSearchSnippet", () => { + let sandbox; + let sendUserActionTelemetryStub; + + function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; + } + + /** + * mountAndCheckProps - Mounts a SimpleBelowSearchSnippet with DEFAULT_CONTENT extended with any props + * passed in the content param and validates props against the schema. + * @param {obj} content Object containing custom message content (e.g. {text, icon}) + * @returns enzyme wrapper for SimpleSnippet + */ + function mountAndCheckProps(content = {}, provider = "test-provider") { + const props = { + content: { ...DEFAULT_CONTENT, ...content }, + provider, + sendUserActionTelemetry: sendUserActionTelemetryStub, + onAction: sandbox.stub(), + }; + assert.jsonSchema(props.content, schema); + return mount( + <SimpleBelowSearchSnippet {...props} />, + mockL10nWrapper(props.content) + ); + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sendUserActionTelemetryStub = sandbox.stub(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render .text", () => { + const wrapper = mountAndCheckProps({ text: "bar" }); + assert.equal(wrapper.find(".body").text(), "bar"); + }); + + it("should render .icon (light theme)", () => { + const wrapper = mountAndCheckProps({ + icon: "data:image/gif;base64,R0lGODl", + }); + assert.equal( + wrapper.find(".icon-light-theme").prop("src"), + "data:image/gif;base64,R0lGODl" + ); + }); + + it("should render .icon (dark theme)", () => { + const wrapper = mountAndCheckProps({ + icon_dark_theme: "data:image/gif;base64,R0lGODl", + }); + assert.equal( + wrapper.find(".icon-dark-theme").prop("src"), + "data:image/gif;base64,R0lGODl" + ); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/SimpleSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/SimpleSnippet.test.jsx new file mode 100644 index 0000000000..7c169525e4 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/SimpleSnippet.test.jsx @@ -0,0 +1,259 @@ +import { mount } from "enzyme"; +import React from "react"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import schema from "content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json"; +import { SimpleSnippet } from "content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx"; + +const DEFAULT_CONTENT = { text: "foo" }; + +describe("SimpleSnippet", () => { + let sandbox; + let onBlockStub; + let sendUserActionTelemetryStub; + + function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; + } + + /** + * mountAndCheckProps - Mounts a SimpleSnippet with DEFAULT_CONTENT extended with any props + * passed in the content param and validates props against the schema. + * @param {obj} content Object containing custom message content (e.g. {text, icon, title}) + * @returns enzyme wrapper for SimpleSnippet + */ + function mountAndCheckProps(content = {}, provider = "test-provider") { + const props = { + content: Object.assign({}, DEFAULT_CONTENT, content), + provider, + onBlock: onBlockStub, + sendUserActionTelemetry: sendUserActionTelemetryStub, + onAction: sandbox.stub(), + }; + assert.jsonSchema(props.content, schema); + return mount(<SimpleSnippet {...props} />, mockL10nWrapper(props.content)); + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + onBlockStub = sandbox.stub(); + sendUserActionTelemetryStub = sandbox.stub(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should have the correct defaults", () => { + const wrapper = mountAndCheckProps(); + [["button", "title", "block_button_text"]].forEach(prop => { + const props = wrapper.find(prop[0]).props(); + assert.propertyVal(props, prop[1], schema.properties[prop[2]].default); + }); + }); + + it("should render .text", () => { + const wrapper = mountAndCheckProps({ text: "bar" }); + assert.equal(wrapper.find(".body").text(), "bar"); + }); + it("should not render title element if no .title prop is supplied", () => { + const wrapper = mountAndCheckProps(); + assert.lengthOf(wrapper.find(".title"), 0); + }); + it("should render .title", () => { + const wrapper = mountAndCheckProps({ title: "Foo" }); + assert.equal(wrapper.find(".title").text().trim(), "Foo"); + }); + it("should render a light theme variant .icon", () => { + const wrapper = mountAndCheckProps({ + icon: "data:image/gif;base64,R0lGODl", + }); + assert.equal( + wrapper.find(".icon-light-theme").prop("src"), + "data:image/gif;base64,R0lGODl" + ); + }); + it("should render a dark theme variant .icon", () => { + const wrapper = mountAndCheckProps({ + icon_dark_theme: "data:image/gif;base64,R0lGODl", + }); + assert.equal( + wrapper.find(".icon-dark-theme").prop("src"), + "data:image/gif;base64,R0lGODl" + ); + }); + it("should render a light theme variant .icon as fallback", () => { + const wrapper = mountAndCheckProps({ + icon_dark_theme: "", + icon: "data:image/gif;base64,R0lGODp", + }); + assert.equal( + wrapper.find(".icon-dark-theme").prop("src"), + "data:image/gif;base64,R0lGODp" + ); + }); + it("should render .button_label and default className", () => { + const wrapper = mountAndCheckProps({ + button_label: "Click here", + button_action: "OPEN_APPLICATIONS_MENU", + button_action_args: "appMenu", + }); + + const button = wrapper.find("button.ASRouterButton"); + button.simulate("click"); + + assert.equal(button.text(), "Click here"); + assert.equal(button.prop("className"), "ASRouterButton secondary"); + assert.calledOnce(wrapper.props().onAction); + assert.calledWithExactly(wrapper.props().onAction, { + type: "OPEN_APPLICATIONS_MENU", + data: { args: "appMenu" }, + }); + }); + it("should not wrap the main content if a section header is not present", () => { + const wrapper = mountAndCheckProps({ text: "bar" }); + assert.lengthOf(wrapper.find(".innerContentWrapper"), 0); + }); + it("should wrap the main content if a section header is present", () => { + const wrapper = mountAndCheckProps({ + section_title_icon: "data:image/gif;base64,R0lGODl", + section_title_text: "Messages from Mozilla", + }); + + assert.lengthOf(wrapper.find(".innerContentWrapper"), 1); + }); + it("should render a section header if text and icon (light-theme) are specified", () => { + const wrapper = mountAndCheckProps({ + section_title_icon: "data:image/gif;base64,R0lGODl", + section_title_text: "Messages from Mozilla", + }); + + assert.equal( + wrapper.find(".section-title .icon-light-theme").prop("style") + .backgroundImage, + 'url("data:image/gif;base64,R0lGODl")' + ); + assert.equal( + wrapper.find(".section-title-text").text().trim(), + "Messages from Mozilla" + ); + // ensure there is no <a> when a section_title_url is not specified + assert.lengthOf(wrapper.find(".section-title a"), 0); + }); + it("should render a section header if text and icon (light-theme) are specified", () => { + const wrapper = mountAndCheckProps({ + section_title_icon: "data:image/gif;base64,R0lGODl", + section_title_icon_dark_theme: "data:image/gif;base64,R0lGODl", + section_title_text: "Messages from Mozilla", + }); + + assert.equal( + wrapper.find(".section-title .icon-dark-theme").prop("style") + .backgroundImage, + 'url("data:image/gif;base64,R0lGODl")' + ); + assert.equal( + wrapper.find(".section-title-text").text().trim(), + "Messages from Mozilla" + ); + // ensure there is no <a> when a section_title_url is not specified + assert.lengthOf(wrapper.find(".section-title a"), 0); + }); + it("should render a section header wrapped in an <a> tag if a url is provided", () => { + const wrapper = mountAndCheckProps({ + section_title_icon: "data:image/gif;base64,R0lGODl", + section_title_text: "Messages from Mozilla", + section_title_url: "https://www.mozilla.org", + }); + + assert.equal( + wrapper.find(".section-title a").prop("href"), + "https://www.mozilla.org" + ); + }); + it("should send an OPEN_URL action when button_url is defined and button is clicked", () => { + const wrapper = mountAndCheckProps({ + button_label: "Button", + button_url: "https://mozilla.org", + }); + + const button = wrapper.find("button.ASRouterButton"); + button.simulate("click"); + + assert.calledOnce(wrapper.props().onAction); + assert.calledWithExactly(wrapper.props().onAction, { + type: "OPEN_URL", + data: { args: "https://mozilla.org" }, + }); + }); + it("should send an OPEN_ABOUT_PAGE action with entrypoint when the button is clicked", () => { + const wrapper = mountAndCheckProps({ + button_label: "Button", + button_action: "OPEN_ABOUT_PAGE", + button_entrypoint_value: "snippet", + button_entrypoint_name: "entryPoint", + button_action_args: "logins", + }); + + const button = wrapper.find("button.ASRouterButton"); + button.simulate("click"); + + assert.calledOnce(wrapper.props().onAction); + assert.calledWithExactly(wrapper.props().onAction, { + type: "OPEN_ABOUT_PAGE", + data: { args: "logins", entrypoint: "entryPoint=snippet" }, + }); + }); + it("should send an OPEN_PREFERENCE_PAGE action with entrypoint when the button is clicked", () => { + const wrapper = mountAndCheckProps({ + button_label: "Button", + button_action: "OPEN_PREFERENCE_PAGE", + button_entrypoint_value: "entry=snippet", + button_action_args: "home", + }); + + const button = wrapper.find("button.ASRouterButton"); + button.simulate("click"); + + assert.calledOnce(wrapper.props().onAction); + assert.calledWithExactly(wrapper.props().onAction, { + type: "OPEN_PREFERENCE_PAGE", + data: { args: "home", entrypoint: "entry=snippet" }, + }); + }); + it("should call props.onBlock and sendUserActionTelemetry when CTA button is clicked", () => { + const wrapper = mountAndCheckProps({ text: "bar" }); + + wrapper.instance().onButtonClick(); + + assert.calledOnce(onBlockStub); + assert.calledOnce(sendUserActionTelemetryStub); + }); + + it("should not call props.onBlock if do_not_autoblock is true", () => { + const wrapper = mountAndCheckProps({ text: "bar", do_not_autoblock: true }); + + wrapper.instance().onButtonClick(); + + assert.notCalled(onBlockStub); + }); + + it("should not call sendUserActionTelemetry for preview message when CTA button is clicked", () => { + const wrapper = mountAndCheckProps({ text: "bar" }, "preview"); + + wrapper.instance().onButtonClick(); + + assert.calledOnce(onBlockStub); + assert.notCalled(sendUserActionTelemetryStub); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/SubmitFormSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/SubmitFormSnippet.test.jsx new file mode 100644 index 0000000000..12e4f96863 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/SubmitFormSnippet.test.jsx @@ -0,0 +1,354 @@ +import { mount } from "enzyme"; +import React from "react"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import { RichText } from "content-src/asrouter/components/RichText/RichText.jsx"; +import schema from "content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json"; +import { SubmitFormSnippet } from "content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx"; + +const DEFAULT_CONTENT = { + scene1_text: "foo", + scene2_text: "bar", + scene1_button_label: "Sign Up", + retry_button_label: "Try again", + form_action: "foo.com", + hidden_inputs: { foo: "foo" }, + error_text: "error", + success_text: "success", +}; + +describe("SubmitFormSnippet", () => { + let sandbox; + let onBlockStub; + + function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; + } + + /** + * mountAndCheckProps - Mounts a SubmitFormSnippet with DEFAULT_CONTENT extended with any props + * passed in the content param and validates props against the schema. + * @param {obj} content Object containing custom message content (e.g. {text, icon, title}) + * @returns enzyme wrapper for SubmitFormSnippet + */ + function mountAndCheckProps(content = {}) { + const props = { + content: Object.assign({}, DEFAULT_CONTENT, content), + onBlock: onBlockStub, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + form_method: "POST", + }; + assert.jsonSchema(props.content, schema); + return mount( + <SubmitFormSnippet {...props} />, + mockL10nWrapper(props.content) + ); + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + onBlockStub = sandbox.stub(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render .text", () => { + const wrapper = mountAndCheckProps({ scene1_text: "bar" }); + assert.equal(wrapper.find(".body").text(), "bar"); + }); + it("should not render title element if no .title prop is supplied", () => { + const wrapper = mountAndCheckProps(); + assert.lengthOf(wrapper.find(".title"), 0); + }); + it("should render .title", () => { + const wrapper = mountAndCheckProps({ scene1_title: "Foo" }); + assert.equal(wrapper.find(".title").text().trim(), "Foo"); + }); + it("should render light-theme .icon", () => { + const wrapper = mountAndCheckProps({ + scene1_icon: "data:image/gif;base64,R0lGODl", + }); + assert.equal( + wrapper.find(".icon-light-theme").prop("src"), + "data:image/gif;base64,R0lGODl" + ); + }); + it("should render dark-theme .icon", () => { + const wrapper = mountAndCheckProps({ + scene1_icon_dark_theme: "data:image/gif;base64,R0lGODl", + }); + assert.equal( + wrapper.find(".icon-dark-theme").prop("src"), + "data:image/gif;base64,R0lGODl" + ); + }); + it("should render .button_label and default className", () => { + const wrapper = mountAndCheckProps({ scene1_button_label: "Click here" }); + + const button = wrapper.find("button.ASRouterButton"); + assert.equal(button.text(), "Click here"); + assert.equal(button.prop("className"), "ASRouterButton secondary"); + }); + + describe("#SignupView", () => { + let wrapper; + const fetchOk = { json: () => Promise.resolve({ status: "ok" }) }; + const fetchFail = { json: () => Promise.resolve({ status: "fail" }) }; + + beforeEach(() => { + wrapper = mountAndCheckProps({ + scene1_text: "bar", + scene2_email_placeholder_text: "Email", + scene2_text: "signup", + }); + }); + + it("should set the input type if provided through props.inputType", () => { + wrapper.setProps({ inputType: "number" }); + wrapper.setState({ expanded: true }); + assert.equal(wrapper.find(".mainInput").instance().type, "number"); + }); + + it("should validate via props.validateInput if provided", () => { + function validateInput(value, content) { + if (content.country === "CA" && value === "poutine") { + return ""; + } + return "Must be poutine"; + } + const setCustomValidity = sandbox.stub(); + wrapper.setProps({ + validateInput, + content: { ...DEFAULT_CONTENT, country: "CA" }, + }); + wrapper.setState({ expanded: true }); + const input = wrapper.find(".mainInput"); + input.instance().value = "poutine"; + input.simulate("change", { + target: { value: "poutine", setCustomValidity }, + }); + assert.calledWith(setCustomValidity, ""); + + input.instance().value = "fried chicken"; + input.simulate("change", { + target: { value: "fried chicken", setCustomValidity }, + }); + assert.calledWith(setCustomValidity, "Must be poutine"); + }); + + it("should show the signup form if state.expanded is true", () => { + wrapper.setState({ expanded: true }); + + assert.isTrue(wrapper.find("form").exists()); + }); + it("should dismiss the snippet", () => { + wrapper.setState({ expanded: true }); + + wrapper.find(".ASRouterButton.secondary").simulate("click"); + + assert.calledOnce(wrapper.props().onDismiss); + }); + it("should send a DISMISS event ping", () => { + wrapper.setState({ expanded: true }); + + wrapper.find(".ASRouterButton.secondary").simulate("click"); + + assert.equal( + wrapper.props().sendUserActionTelemetry.firstCall.args[0].event, + "DISMISS" + ); + }); + it("should render hidden inputs + email input", () => { + wrapper.setState({ expanded: true }); + + assert.lengthOf(wrapper.find("input[type='hidden']"), 1); + }); + it("should open the SignupView when the action button is clicked", () => { + assert.isFalse(wrapper.find("form").exists()); + + wrapper.find(".ASRouterButton").simulate("click"); + + assert.isTrue(wrapper.state().expanded); + assert.isTrue(wrapper.find("form").exists()); + }); + it("should submit telemetry when the action button is clicked", () => { + assert.isFalse(wrapper.find("form").exists()); + + wrapper.find(".ASRouterButton").simulate("click"); + + assert.equal( + wrapper.props().sendUserActionTelemetry.firstCall.args[0].event_context, + "scene1-button-learn-more" + ); + }); + it("should submit form data when submitted", () => { + sandbox.stub(window, "fetch").resolves(fetchOk); + wrapper.setState({ expanded: true }); + + wrapper.find("form").simulate("submit"); + assert.calledOnce(window.fetch); + }); + it("should send user telemetry when submitted", () => { + wrapper.setState({ expanded: true }); + + wrapper.find("form").simulate("submit"); + + assert.equal( + wrapper.props().sendUserActionTelemetry.firstCall.args[0].event_context, + "conversion-subscribe-activation" + ); + }); + it("should set signupSuccess when submission status is ok", async () => { + sandbox.stub(window, "fetch").resolves(fetchOk); + wrapper.setState({ expanded: true }); + await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() }); + + assert.equal(wrapper.state().signupSuccess, true); + assert.equal(wrapper.state().signupSubmitted, true); + assert.calledOnce(onBlockStub); + assert.calledWithExactly(onBlockStub, { preventDismiss: true }); + }); + it("should send user telemetry when submission status is ok", async () => { + sandbox.stub(window, "fetch").resolves(fetchOk); + wrapper.setState({ expanded: true }); + await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() }); + + assert.equal( + wrapper.props().sendUserActionTelemetry.secondCall.args[0] + .event_context, + "subscribe-success" + ); + }); + it("should not block the snippet if submission failed", async () => { + sandbox.stub(window, "fetch").resolves(fetchFail); + wrapper.setState({ expanded: true }); + await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() }); + + assert.equal(wrapper.state().signupSuccess, false); + assert.equal(wrapper.state().signupSubmitted, true); + assert.notCalled(onBlockStub); + }); + it("should not block if do_not_autoblock is true", async () => { + sandbox.stub(window, "fetch").resolves(fetchOk); + wrapper = mountAndCheckProps({ + scene1_text: "bar", + scene2_email_placeholder_text: "Email", + scene2_text: "signup", + do_not_autoblock: true, + }); + wrapper.setState({ expanded: true }); + await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() }); + + assert.equal(wrapper.state().signupSuccess, true); + assert.equal(wrapper.state().signupSubmitted, true); + assert.notCalled(onBlockStub); + }); + it("should send user telemetry if submission failed", async () => { + sandbox.stub(window, "fetch").resolves(fetchFail); + wrapper.setState({ expanded: true }); + await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() }); + + assert.equal( + wrapper.props().sendUserActionTelemetry.secondCall.args[0] + .event_context, + "subscribe-error" + ); + }); + it("should render the signup success message", () => { + wrapper.setProps({ content: { success_text: "success" } }); + wrapper.setState({ signupSuccess: true, signupSubmitted: true }); + + assert.isTrue(wrapper.find(".submissionStatus").exists()); + assert.propertyVal( + wrapper.find(RichText).props(), + "localization_id", + "success_text" + ); + assert.propertyVal( + wrapper.find(RichText).props(), + "success_text", + "success" + ); + assert.isFalse(wrapper.find(".ASRouterButton").exists()); + }); + it("should render the signup error message", () => { + wrapper.setProps({ content: { error_text: "trouble" } }); + wrapper.setState({ signupSuccess: false, signupSubmitted: true }); + + assert.isTrue(wrapper.find(".submissionStatus").exists()); + assert.propertyVal( + wrapper.find(RichText).props(), + "localization_id", + "error_text" + ); + assert.propertyVal( + wrapper.find(RichText).props(), + "error_text", + "trouble" + ); + assert.isTrue(wrapper.find(".ASRouterButton").exists()); + }); + it("should render the button to return to the signup form if there was an error", () => { + wrapper.setState({ signupSubmitted: true, signupSuccess: false }); + + const button = wrapper.find("button.ASRouterButton"); + assert.equal(button.text(), "Try again"); + wrapper.find(".ASRouterButton").simulate("click"); + + assert.equal(wrapper.state().signupSubmitted, false); + }); + it("should not render the privacy notice checkbox if prop is missing", () => { + wrapper.setState({ expanded: true }); + + assert.isFalse(wrapper.find(".privacyNotice").exists()); + }); + it("should render the privacy notice checkbox if prop is provided", () => { + wrapper.setProps({ + content: { ...DEFAULT_CONTENT, scene2_privacy_html: "privacy notice" }, + }); + wrapper.setState({ expanded: true }); + + assert.isTrue(wrapper.find(".privacyNotice").exists()); + }); + it("should not call fetch if form_method is GET", async () => { + sandbox.stub(window, "fetch").resolves(fetchOk); + wrapper.setProps({ form_method: "GET" }); + wrapper.setState({ expanded: true }); + + await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() }); + + assert.notCalled(window.fetch); + }); + it("should block the snippet when form_method is GET", () => { + wrapper.setProps({ form_method: "GET" }); + wrapper.setState({ expanded: true }); + + wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() }); + + assert.calledOnce(onBlockStub); + assert.calledWithExactly(onBlockStub, { preventDismiss: true }); + }); + it("should return to scene 2 alt when clicking the retry button", async () => { + wrapper.setState({ signupSubmitted: true }); + wrapper.setProps({ expandedAlt: true }); + + wrapper.find(".ASRouterButton").simulate("click"); + + assert.isTrue(wrapper.find(".scene2Alt").exists()); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/isEmailOrPhoneNumber.test.js b/browser/components/newtab/test/unit/asrouter/templates/isEmailOrPhoneNumber.test.js new file mode 100644 index 0000000000..32eaf2160e --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/isEmailOrPhoneNumber.test.js @@ -0,0 +1,56 @@ +import { isEmailOrPhoneNumber } from "content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber"; + +const CONTENT = {}; + +describe("isEmailOrPhoneNumber", () => { + it("should return 'email' for emails", () => { + assert.equal(isEmailOrPhoneNumber("foobar@asd.com", CONTENT), "email"); + assert.equal(isEmailOrPhoneNumber("foobar@asd.co.uk", CONTENT), "email"); + }); + it("should return 'phone' for valid en-US/en-CA phone numbers", () => { + assert.equal( + isEmailOrPhoneNumber("14582731273", { locale: "en-US" }), + "phone" + ); + assert.equal( + isEmailOrPhoneNumber("4582731273", { locale: "en-CA" }), + "phone" + ); + }); + it("should return an empty string for invalid phone number lengths in en-US/en-CA", () => { + // Not enough digits + assert.equal(isEmailOrPhoneNumber("4522", { locale: "en-US" }), ""); + assert.equal(isEmailOrPhoneNumber("4522", { locale: "en-CA" }), ""); + }); + it("should return 'phone' for valid German phone numbers", () => { + assert.equal( + isEmailOrPhoneNumber("145827312732", { locale: "de" }), + "phone" + ); + }); + it("should return 'phone' for any number of digits in other locales", () => { + assert.equal(isEmailOrPhoneNumber("4", CONTENT), "phone"); + }); + it("should return an empty string for other invalid inputs", () => { + assert.equal( + isEmailOrPhoneNumber("abc", CONTENT), + "", + "abc should be invalid" + ); + assert.equal( + isEmailOrPhoneNumber("abc@", CONTENT), + "", + "abc@ should be invalid" + ); + assert.equal( + isEmailOrPhoneNumber("abc@foo", CONTENT), + "", + "abc@foo should be invalid" + ); + assert.equal( + isEmailOrPhoneNumber("123d1232", CONTENT), + "", + "123d1232 should be invalid" + ); + }); +}); |