diff options
Diffstat (limited to 'browser/components/newtab/test/unit/asrouter/CFRPageActions.test.js')
-rw-r--r-- | browser/components/newtab/test/unit/asrouter/CFRPageActions.test.js | 1203 |
1 files changed, 1203 insertions, 0 deletions
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..131736d90e --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/CFRPageActions.test.js @@ -0,0 +1,1203 @@ +/* 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.jsm"; + +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 * 17).toFixed(1)}px` + ); + assert.equal( + footerEmptyStars.style.width, + `${(0.8 * 17).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} 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, + } + ); + }); + }); + 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 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 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); + }); + }); + }); +}); |