CFRPageActions.test.js 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825
  1. import {CFRPageActions, PageAction} from "lib/CFRPageActions.jsm";
  2. import {FAKE_RECOMMENDATION} from "./constants";
  3. import {GlobalOverrider} from "test/unit/utils";
  4. describe("CFRPageActions", () => {
  5. let sandbox;
  6. let clock;
  7. let fakeRecommendation;
  8. let fakeHost;
  9. let fakeBrowser;
  10. let dispatchStub;
  11. let globals;
  12. let containerElem;
  13. let elements;
  14. const elementIDs = [
  15. "urlbar",
  16. "contextual-feature-recommendation",
  17. "cfr-button",
  18. "cfr-label",
  19. "contextual-feature-recommendation-notification",
  20. "cfr-notification-header-label",
  21. "cfr-notification-header-link",
  22. "cfr-notification-header-image",
  23. "cfr-notification-author",
  24. "cfr-notification-footer",
  25. "cfr-notification-footer-text",
  26. "cfr-notification-footer-filled-stars",
  27. "cfr-notification-footer-empty-stars",
  28. "cfr-notification-footer-users",
  29. "cfr-notification-footer-spacer",
  30. "cfr-notification-footer-learn-more-link",
  31. "cfr-notification-footer-pintab-animation-container",
  32. "cfr-notification-footer-animation-button",
  33. "cfr-notification-footer-animation-label",
  34. ];
  35. const elementClassNames = [
  36. "popup-notification-body-container",
  37. ];
  38. beforeEach(() => {
  39. sandbox = sinon.createSandbox();
  40. clock = sandbox.useFakeTimers();
  41. fakeRecommendation = {...FAKE_RECOMMENDATION};
  42. fakeHost = "mozilla.org";
  43. fakeBrowser = {
  44. documentURI: {
  45. scheme: "https",
  46. host: fakeHost,
  47. },
  48. ownerGlobal: window,
  49. };
  50. dispatchStub = sandbox.stub();
  51. globals = new GlobalOverrider();
  52. globals.set({
  53. DOMLocalization: class {},
  54. promiseDocumentFlushed: sandbox.stub().callsFake(fn => Promise.resolve(fn())),
  55. PopupNotifications: {
  56. show: sandbox.stub(),
  57. remove: sandbox.stub(),
  58. },
  59. PrivateBrowsingUtils: {isWindowPrivate: sandbox.stub().returns(false)},
  60. gBrowser: {selectedBrowser: fakeBrowser},
  61. });
  62. document.createXULElement = document.createElement;
  63. elements = {};
  64. const [body] = document.getElementsByTagName("body");
  65. containerElem = document.createElement("div");
  66. body.appendChild(containerElem);
  67. for (const id of elementIDs) {
  68. const elem = document.createElement("div");
  69. elem.setAttribute("id", id);
  70. containerElem.appendChild(elem);
  71. elements[id] = elem;
  72. }
  73. for (const className of elementClassNames) {
  74. const elem = document.createElement("div");
  75. elem.setAttribute("class", className);
  76. containerElem.appendChild(elem);
  77. elements[className] = elem;
  78. }
  79. });
  80. afterEach(() => {
  81. CFRPageActions.clearRecommendations();
  82. containerElem.remove();
  83. sandbox.restore();
  84. globals.restore();
  85. });
  86. describe("PageAction", () => {
  87. let pageAction;
  88. let getStringsStub;
  89. beforeEach(() => {
  90. pageAction = new PageAction(window, dispatchStub);
  91. getStringsStub = sandbox.stub(pageAction, "getStrings").resolves("");
  92. });
  93. describe("#showAddressBarNotifier", () => {
  94. it("should un-hideAddressBarNotifier the element and set the right label value", async () => {
  95. const FAKE_NOTIFICATION_TEXT = "FAKE_NOTIFICATION_TEXT";
  96. getStringsStub.withArgs(fakeRecommendation.content.notification_text).resolves(FAKE_NOTIFICATION_TEXT);
  97. await pageAction.showAddressBarNotifier(fakeRecommendation);
  98. assert.isFalse(pageAction.container.hidden);
  99. assert.equal(pageAction.label.value, FAKE_NOTIFICATION_TEXT);
  100. });
  101. it("should wait for the document layout to flush", async () => {
  102. sandbox.spy(pageAction.label, "getClientRects");
  103. await pageAction.showAddressBarNotifier(fakeRecommendation);
  104. assert.calledOnce(global.promiseDocumentFlushed);
  105. assert.callOrder(global.promiseDocumentFlushed, pageAction.label.getClientRects);
  106. });
  107. it("should set the CSS variable --cfr-label-width correctly", async () => {
  108. await pageAction.showAddressBarNotifier(fakeRecommendation);
  109. const expectedWidth = pageAction.label.getClientRects()[0].width;
  110. assert.equal(pageAction.urlbar.style.getPropertyValue("--cfr-label-width"),
  111. `${expectedWidth}px`);
  112. });
  113. it("should cause an expansion, and dispatch an impression iff `expand` is true", async () => {
  114. sandbox.spy(pageAction, "_clearScheduledStateChanges");
  115. sandbox.spy(pageAction, "_expand");
  116. sandbox.spy(pageAction, "_dispatchImpression");
  117. await pageAction.showAddressBarNotifier(fakeRecommendation);
  118. assert.notCalled(pageAction._dispatchImpression);
  119. clock.tick(1001);
  120. assert.notEqual(pageAction.urlbar.getAttribute("cfr-recommendation-state"), "expanded");
  121. await pageAction.showAddressBarNotifier(fakeRecommendation, true);
  122. assert.calledOnce(pageAction._clearScheduledStateChanges);
  123. clock.tick(1001);
  124. assert.equal(pageAction.urlbar.getAttribute("cfr-recommendation-state"), "expanded");
  125. assert.calledOnce(pageAction._dispatchImpression);
  126. assert.calledWith(pageAction._dispatchImpression, fakeRecommendation);
  127. });
  128. it("should send telemetry if `expand` is true and the id and bucket_id are provided", async () => {
  129. await pageAction.showAddressBarNotifier(fakeRecommendation, true);
  130. assert.calledWith(dispatchStub, {
  131. type: "DOORHANGER_TELEMETRY",
  132. data: {
  133. action: "cfr_user_event",
  134. source: "CFR",
  135. message_id: fakeRecommendation.id,
  136. bucket_id: fakeRecommendation.content.bucket_id,
  137. event: "IMPRESSION",
  138. },
  139. });
  140. });
  141. });
  142. describe("#hideAddressBarNotifier", () => {
  143. it("should hideAddressBarNotifier the container, cancel any state changes, and remove the state attribute", () => {
  144. sandbox.spy(pageAction, "_clearScheduledStateChanges");
  145. pageAction.hideAddressBarNotifier();
  146. assert.isTrue(pageAction.container.hidden);
  147. assert.calledOnce(pageAction._clearScheduledStateChanges);
  148. assert.isNull(pageAction.urlbar.getAttribute("cfr-recommendation-state"));
  149. });
  150. it("should remove the `currentNotification`", () => {
  151. const notification = {};
  152. pageAction.currentNotification = notification;
  153. pageAction.hideAddressBarNotifier();
  154. assert.calledWith(global.PopupNotifications.remove, notification);
  155. });
  156. });
  157. describe("#_expand", () => {
  158. beforeEach(() => {
  159. pageAction._clearScheduledStateChanges();
  160. pageAction.urlbar.removeAttribute("cfr-recommendation-state");
  161. });
  162. it("without a delay, should clear other state changes and set the state to 'expanded'", () => {
  163. sandbox.spy(pageAction, "_clearScheduledStateChanges");
  164. pageAction._expand();
  165. assert.calledOnce(pageAction._clearScheduledStateChanges);
  166. assert.equal(pageAction.urlbar.getAttribute("cfr-recommendation-state"), "expanded");
  167. });
  168. it("with a delay, should set the expanded state after the correct amount of time", () => {
  169. const delay = 1234;
  170. pageAction._expand(delay);
  171. // We expect that an expansion has been scheduled
  172. assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1);
  173. clock.tick(delay + 1);
  174. assert.equal(pageAction.urlbar.getAttribute("cfr-recommendation-state"), "expanded");
  175. });
  176. });
  177. describe("#_collapse", () => {
  178. beforeEach(() => {
  179. pageAction._clearScheduledStateChanges();
  180. pageAction.urlbar.removeAttribute("cfr-recommendation-state");
  181. });
  182. it("without a delay, should clear other state changes and set the state to collapsed only if it's already expanded", () => {
  183. sandbox.spy(pageAction, "_clearScheduledStateChanges");
  184. pageAction._collapse();
  185. assert.calledOnce(pageAction._clearScheduledStateChanges);
  186. assert.isNull(pageAction.urlbar.getAttribute("cfr-recommendation-state"));
  187. pageAction.urlbar.setAttribute("cfr-recommendation-state", "expanded");
  188. pageAction._collapse();
  189. assert.equal(pageAction.urlbar.getAttribute("cfr-recommendation-state"), "collapsed");
  190. });
  191. it("with a delay, should set the collapsed state after the correct amount of time", () => {
  192. const delay = 1234;
  193. pageAction._collapse(delay);
  194. clock.tick(delay + 1);
  195. // The state was _not_ "expanded" and so should not have been set to "collapsed"
  196. assert.isNull(pageAction.urlbar.getAttribute("cfr-recommendation-state"));
  197. pageAction._expand();
  198. pageAction._collapse(delay);
  199. // We expect that a collapse has been scheduled
  200. assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1);
  201. clock.tick(delay + 1);
  202. // This time it was "expanded" so should now (after the delay) be "collapsed"
  203. assert.equal(pageAction.urlbar.getAttribute("cfr-recommendation-state"), "collapsed");
  204. });
  205. });
  206. describe("#_clearScheduledStateChanges", () => {
  207. it("should call .clearTimeout on all stored timeoutIDs", () => {
  208. pageAction.stateTransitionTimeoutIDs = [42, 73, 1997];
  209. sandbox.spy(pageAction.window, "clearTimeout");
  210. pageAction._clearScheduledStateChanges();
  211. assert.calledThrice(pageAction.window.clearTimeout);
  212. assert.calledWith(pageAction.window.clearTimeout, 42);
  213. assert.calledWith(pageAction.window.clearTimeout, 73);
  214. assert.calledWith(pageAction.window.clearTimeout, 1997);
  215. });
  216. });
  217. describe("#_popupStateChange", () => {
  218. it("should collapse and remove the notification on 'dismissed'", () => {
  219. pageAction._expand();
  220. const fakeNotification = {};
  221. pageAction.currentNotification = fakeNotification;
  222. pageAction._popupStateChange("dismissed");
  223. assert.equal(pageAction.urlbar.getAttribute("cfr-recommendation-state"), "collapsed");
  224. assert.calledOnce(global.PopupNotifications.remove);
  225. assert.calledWith(global.PopupNotifications.remove, fakeNotification);
  226. });
  227. it("should collapse and remove the notification on 'removed'", () => {
  228. pageAction._expand();
  229. const fakeNotification = {};
  230. pageAction.currentNotification = fakeNotification;
  231. pageAction._popupStateChange("removed");
  232. assert.equal(pageAction.urlbar.getAttribute("cfr-recommendation-state"), "collapsed");
  233. assert.calledOnce(global.PopupNotifications.remove);
  234. assert.calledWith(global.PopupNotifications.remove, fakeNotification);
  235. });
  236. it("should do nothing for other states", () => {
  237. pageAction._popupStateChange("opened");
  238. assert.notCalled(global.PopupNotifications.remove);
  239. });
  240. });
  241. describe("#dispatchUserAction", () => {
  242. it("should call ._dispatchToASRouter with the right action", () => {
  243. const fakeAction = {};
  244. pageAction.dispatchUserAction(fakeAction);
  245. assert.calledOnce(dispatchStub);
  246. assert.calledWith(
  247. dispatchStub,
  248. {type: "USER_ACTION", data: fakeAction},
  249. {browser: fakeBrowser}
  250. );
  251. });
  252. });
  253. describe("#_dispatchImpression", () => {
  254. it("should call ._dispatchToASRouter with the right action", () => {
  255. pageAction._dispatchImpression("fake impression");
  256. assert.calledWith(dispatchStub, {type: "IMPRESSION", data: "fake impression"});
  257. });
  258. });
  259. describe("#_sendTelemetry", () => {
  260. it("should call ._dispatchToASRouter with the right action", () => {
  261. const fakePing = {message_id: 42};
  262. pageAction._sendTelemetry(fakePing);
  263. assert.calledWith(dispatchStub, {
  264. type: "DOORHANGER_TELEMETRY",
  265. data: {action: "cfr_user_event", source: "CFR", message_id: 42},
  266. });
  267. });
  268. });
  269. describe("#_blockMessage", () => {
  270. it("should call ._dispatchToASRouter with the right action", () => {
  271. pageAction._blockMessage("fake id");
  272. assert.calledOnce(dispatchStub);
  273. assert.calledWith(dispatchStub, {
  274. type: "BLOCK_MESSAGE_BY_ID",
  275. data: {id: "fake id"},
  276. });
  277. });
  278. });
  279. describe("#getStrings", () => {
  280. let formatMessagesStub;
  281. const localeStrings = [{
  282. value: "你好世界",
  283. attributes: [
  284. {name: "first_attr", value: 42},
  285. {name: "second_attr", value: "some string"},
  286. {name: "third_attr", value: [1, 2, 3]},
  287. ],
  288. }];
  289. beforeEach(() => {
  290. getStringsStub.restore();
  291. formatMessagesStub = sandbox.stub()
  292. .withArgs({id: "hello_world"})
  293. .resolves(localeStrings);
  294. global.DOMLocalization.prototype.formatMessages = formatMessagesStub;
  295. });
  296. it("should return the argument if a string_id is not defined", async () => {
  297. assert.deepEqual(await pageAction.getStrings({}), {});
  298. assert.equal(await pageAction.getStrings("some string"), "some string");
  299. });
  300. it("should get the right locale string", async () => {
  301. assert.equal(await pageAction.getStrings({string_id: "hello_world"}), localeStrings[0].value);
  302. });
  303. it("should return the right sub-attribute if specified", async () => {
  304. assert.equal(await pageAction.getStrings({string_id: "hello_world"}, "second_attr"), "some string");
  305. });
  306. it("should attach attributes to string overrides", async () => {
  307. const fromJson = {value: "Add Now", attributes: {accesskey: "A"}};
  308. const result = await pageAction.getStrings(fromJson);
  309. assert.equal(result, fromJson.value);
  310. assert.propertyVal(result.attributes, "accesskey", "A");
  311. });
  312. it("should return subAttributes when doing string overrides", async () => {
  313. const fromJson = {value: "Add Now", attributes: {accesskey: "A"}};
  314. const result = await pageAction.getStrings(fromJson, "accesskey");
  315. assert.equal(result, "A");
  316. });
  317. it("should resolve ftl strings and attach subAttributes", async () => {
  318. const fromFtl = {string_id: "cfr-doorhanger-extension-ok-button"};
  319. formatMessagesStub.resolves([{value: "Add Now", attributes: [{name: "accesskey", value: "A"}]}]);
  320. const result = await pageAction.getStrings(fromFtl);
  321. assert.equal(result, "Add Now");
  322. assert.propertyVal(result.attributes, "accesskey", "A");
  323. });
  324. it("should return subAttributes from ftl ids", async () => {
  325. const fromFtl = {string_id: "cfr-doorhanger-extension-ok-button"};
  326. formatMessagesStub.resolves([{value: "Add Now", attributes: [{name: "accesskey", value: "A"}]}]);
  327. const result = await pageAction.getStrings(fromFtl, "accesskey");
  328. assert.equal(result, "A");
  329. });
  330. it("should report an error when no attributes are present but subAttribute is requested", async () => {
  331. const fromJson = {value: "Foo"};
  332. const stub = sandbox.stub(global.Cu, "reportError");
  333. await pageAction.getStrings(fromJson, "accesskey");
  334. assert.calledOnce(stub);
  335. stub.restore();
  336. });
  337. });
  338. describe("#_showPopupOnClick", () => {
  339. let translateElementsStub;
  340. let setAttributesStub;
  341. beforeEach(async () => {
  342. CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction);
  343. await CFRPageActions.addRecommendation(fakeBrowser, fakeHost, fakeRecommendation, dispatchStub);
  344. getStringsStub.callsFake(async a => a) // eslint-disable-line max-nested-callbacks
  345. .withArgs({string_id: "primary_button_id"})
  346. .resolves({value: "Primary Button", attributes: {accesskey: "p"}})
  347. .withArgs({string_id: "secondary_button_id"})
  348. .resolves({value: "Secondary Button", attributes: {accesskey: "s"}})
  349. .withArgs({string_id: "secondary_button_id_2"})
  350. .resolves({value: "Secondary Button 2", attributes: {accesskey: "a"}})
  351. .withArgs({string_id: "secondary_button_id_3"})
  352. .resolves({value: "Secondary Button 3", attributes: {accesskey: "g"}})
  353. .withArgs(sinon.match({string_id: "cfr-doorhanger-extension-learn-more-link"}))
  354. .resolves("Learn more")
  355. .withArgs(sinon.match({string_id: "cfr-doorhanger-extension-total-users"}))
  356. .callsFake(async ({args}) => `${args.total} users`); // eslint-disable-line max-nested-callbacks
  357. translateElementsStub = sandbox.stub().resolves();
  358. setAttributesStub = sandbox.stub();
  359. global.DOMLocalization.prototype.setAttributes = setAttributesStub;
  360. global.DOMLocalization.prototype.translateElements = translateElementsStub;
  361. });
  362. it("should call `.hideAddressBarNotifier` and do nothing if there is no recommendation for the selected browser", async () => {
  363. sandbox.spy(pageAction, "hideAddressBarNotifier");
  364. CFRPageActions.RecommendationMap.delete(fakeBrowser);
  365. await pageAction._showPopupOnClick({});
  366. assert.calledOnce(pageAction.hideAddressBarNotifier);
  367. assert.notCalled(global.PopupNotifications.show);
  368. });
  369. it("should cancel any planned state changes", async () => {
  370. sandbox.spy(pageAction, "_clearScheduledStateChanges");
  371. assert.notCalled(pageAction._clearScheduledStateChanges);
  372. await pageAction._showPopupOnClick({});
  373. assert.calledOnce(pageAction._clearScheduledStateChanges);
  374. });
  375. it("should set the right text values", async () => {
  376. await pageAction._showPopupOnClick({});
  377. const headerLabel = elements["cfr-notification-header-label"];
  378. const headerLink = elements["cfr-notification-header-link"];
  379. const headerImage = elements["cfr-notification-header-image"];
  380. const footerText = elements["cfr-notification-footer-text"];
  381. const footerLink = elements["cfr-notification-footer-learn-more-link"];
  382. assert.equal(headerLabel.value, fakeRecommendation.content.heading_text);
  383. assert.isTrue(headerLink.getAttribute("href").endsWith(fakeRecommendation.content.info_icon.sumo_path));
  384. assert.equal(headerImage.getAttribute("tooltiptext"), fakeRecommendation.content.info_icon.label);
  385. assert.equal(footerText.textContent, fakeRecommendation.content.text);
  386. assert.equal(footerLink.value, "Learn more");
  387. assert.equal(footerLink.getAttribute("href"), fakeRecommendation.content.addon.amo_url);
  388. });
  389. it("should add the rating correctly", async () => {
  390. await pageAction._showPopupOnClick();
  391. const footerFilledStars = elements["cfr-notification-footer-filled-stars"];
  392. const footerEmptyStars = elements["cfr-notification-footer-empty-stars"];
  393. // .toFixed to sort out some floating precision errors
  394. assert.equal(footerFilledStars.style.width, `${(4.2 * 17).toFixed(1)}px`);
  395. assert.equal(footerEmptyStars.style.width, `${(0.8 * 17).toFixed(1)}px`);
  396. });
  397. it("should add the number of users correctly", async () => {
  398. await pageAction._showPopupOnClick();
  399. const footerUsers = elements["cfr-notification-footer-users"];
  400. assert.isNull(footerUsers.getAttribute("hidden"));
  401. assert.equal(footerUsers.getAttribute("value"), `${fakeRecommendation.content.addon.users} users`);
  402. });
  403. it("should send the right telemetry", async () => {
  404. await pageAction._showPopupOnClick();
  405. assert.calledWith(dispatchStub, {
  406. type: "DOORHANGER_TELEMETRY",
  407. data: {
  408. action: "cfr_user_event",
  409. source: "CFR",
  410. message_id: fakeRecommendation.id,
  411. bucket_id: fakeRecommendation.content.bucket_id,
  412. event: "CLICK_DOORHANGER",
  413. },
  414. });
  415. });
  416. it("should set the main action correctly", async () => {
  417. sinon.stub(CFRPageActions, "_fetchLatestAddonVersion").resolves("latest-addon.xpi");
  418. await pageAction._showPopupOnClick();
  419. const mainAction = global.PopupNotifications.show.firstCall.args[4]; // eslint-disable-line prefer-destructuring
  420. assert.deepEqual(mainAction.label, {value: "Primary Button", attributes: {accesskey: "p"}});
  421. sandbox.spy(pageAction, "hideAddressBarNotifier");
  422. await mainAction.callback();
  423. assert.calledOnce(pageAction.hideAddressBarNotifier);
  424. // Should block the message
  425. assert.calledWith(dispatchStub, {
  426. type: "BLOCK_MESSAGE_BY_ID",
  427. data: {id: fakeRecommendation.id},
  428. });
  429. // Should trigger the action
  430. assert.calledWith(
  431. dispatchStub,
  432. {type: "USER_ACTION", data: {id: "primary_action", data: {url: "latest-addon.xpi"}}},
  433. {browser: fakeBrowser}
  434. );
  435. // Should send telemetry
  436. assert.calledWith(dispatchStub, {
  437. type: "DOORHANGER_TELEMETRY",
  438. data: {
  439. action: "cfr_user_event",
  440. source: "CFR",
  441. message_id: fakeRecommendation.id,
  442. bucket_id: fakeRecommendation.content.bucket_id,
  443. event: "INSTALL",
  444. },
  445. });
  446. // Should remove the recommendation
  447. assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
  448. });
  449. it("should set the secondary action correctly", async () => {
  450. await pageAction._showPopupOnClick();
  451. const [secondaryAction] = global.PopupNotifications.show.firstCall.args[5]; // eslint-disable-line prefer-destructuring
  452. assert.deepEqual(secondaryAction.label, {value: "Secondary Button", attributes: {accesskey: "s"}});
  453. sandbox.spy(pageAction, "hideAddressBarNotifier");
  454. CFRPageActions.RecommendationMap.set(fakeBrowser, {});
  455. secondaryAction.callback();
  456. // Should send telemetry
  457. assert.calledWith(dispatchStub, {
  458. type: "DOORHANGER_TELEMETRY",
  459. data: {
  460. action: "cfr_user_event",
  461. source: "CFR",
  462. message_id: fakeRecommendation.id,
  463. bucket_id: fakeRecommendation.content.bucket_id,
  464. event: "DISMISS",
  465. },
  466. });
  467. // Don't remove the recommendation on `DISMISS` action
  468. assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
  469. assert.notCalled(pageAction.hideAddressBarNotifier);
  470. });
  471. it("should send right telemetry for BLOCK secondary action", async () => {
  472. await pageAction._showPopupOnClick();
  473. const blockAction = global.PopupNotifications.show.firstCall.args[5][1]; // eslint-disable-line prefer-destructuring
  474. assert.deepEqual(blockAction.label, {value: "Secondary Button 2", attributes: {accesskey: "a"}});
  475. sandbox.spy(pageAction, "hideAddressBarNotifier");
  476. sandbox.spy(pageAction, "_blockMessage");
  477. CFRPageActions.RecommendationMap.set(fakeBrowser, {});
  478. blockAction.callback();
  479. assert.calledOnce(pageAction.hideAddressBarNotifier);
  480. assert.calledOnce(pageAction._blockMessage);
  481. // Should send telemetry
  482. assert.calledWith(dispatchStub, {
  483. type: "DOORHANGER_TELEMETRY",
  484. data: {
  485. action: "cfr_user_event",
  486. source: "CFR",
  487. message_id: fakeRecommendation.id,
  488. bucket_id: fakeRecommendation.content.bucket_id,
  489. event: "BLOCK",
  490. },
  491. });
  492. // Should remove the recommendation
  493. assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
  494. });
  495. it("should send right telemetry for MANAGE secondary action", async () => {
  496. await pageAction._showPopupOnClick();
  497. const manageAction = global.PopupNotifications.show.firstCall.args[5][2]; // eslint-disable-line prefer-destructuring
  498. assert.deepEqual(manageAction.label, {value: "Secondary Button 3", attributes: {accesskey: "g"}});
  499. sandbox.spy(pageAction, "hideAddressBarNotifier");
  500. CFRPageActions.RecommendationMap.set(fakeBrowser, {});
  501. manageAction.callback();
  502. // Should send telemetry
  503. assert.calledWith(dispatchStub, {
  504. type: "DOORHANGER_TELEMETRY",
  505. data: {
  506. action: "cfr_user_event",
  507. source: "CFR",
  508. message_id: fakeRecommendation.id,
  509. bucket_id: fakeRecommendation.content.bucket_id,
  510. event: "MANAGE",
  511. },
  512. });
  513. // Don't remove the recommendation on `MANAGE` action
  514. assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
  515. assert.notCalled(pageAction.hideAddressBarNotifier);
  516. });
  517. it("should call PopupNotifications.show with the right arguments", async () => {
  518. await pageAction._showPopupOnClick();
  519. assert.calledWith(
  520. global.PopupNotifications.show,
  521. fakeBrowser,
  522. "contextual-feature-recommendation",
  523. fakeRecommendation.content.addon.title,
  524. "cfr",
  525. sinon.match.any, // Corresponds to the main action, tested above
  526. sinon.match.any, // Corresponds to the secondary action, tested above
  527. {
  528. popupIconURL: fakeRecommendation.content.addon.icon,
  529. hideClose: true,
  530. eventCallback: pageAction._popupStateChange,
  531. }
  532. );
  533. });
  534. it("should show the bullet list details", async () => {
  535. delete fakeRecommendation.content.addon;
  536. await pageAction._showPopupOnClick();
  537. assert.calledOnce(translateElementsStub);
  538. });
  539. it("should set the data-l10n-id on the list element", async () => {
  540. delete fakeRecommendation.content.addon;
  541. await pageAction._showPopupOnClick();
  542. assert.calledOnce(setAttributesStub);
  543. assert.calledWith(setAttributesStub, sinon.match.any, fakeRecommendation.content.descriptionDetails.steps[0].string_id);
  544. });
  545. it("should set the correct data-notification-category", async () => {
  546. delete fakeRecommendation.content.addon;
  547. await pageAction._showPopupOnClick();
  548. assert.equal(elements["contextual-feature-recommendation-notification"].dataset.notificationCategory, fakeRecommendation.content.category);
  549. });
  550. it("should send PIN event on primary action click", async () => {
  551. sandbox.stub(pageAction, "_sendTelemetry");
  552. delete fakeRecommendation.content.addon;
  553. await pageAction._showPopupOnClick();
  554. const [, , , , {callback}] = global.PopupNotifications.show.firstCall.args;
  555. callback();
  556. // First call is triggered by `_showPopupOnClick`
  557. assert.propertyVal(pageAction._sendTelemetry.secondCall.args[0], "event", "PIN");
  558. });
  559. });
  560. });
  561. describe("CFRPageActions", () => {
  562. beforeEach(() => {
  563. // Spy on the prototype methods to inspect calls for any PageAction instance
  564. sandbox.spy(PageAction.prototype, "showAddressBarNotifier");
  565. sandbox.spy(PageAction.prototype, "hideAddressBarNotifier");
  566. });
  567. describe("updatePageActions", () => {
  568. let savedRec;
  569. beforeEach(() => {
  570. const win = fakeBrowser.ownerGlobal;
  571. CFRPageActions.PageActionMap.set(win, new PageAction(win, dispatchStub));
  572. const {id, content} = fakeRecommendation;
  573. savedRec = {
  574. id,
  575. host: fakeHost,
  576. content,
  577. };
  578. CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec);
  579. });
  580. it("should do nothing if a pageAction doesn't exist for the window", () => {
  581. const win = fakeBrowser.ownerGlobal;
  582. CFRPageActions.PageActionMap.delete(win);
  583. CFRPageActions.updatePageActions(fakeBrowser);
  584. assert.notCalled(PageAction.prototype.showAddressBarNotifier);
  585. assert.notCalled(PageAction.prototype.hideAddressBarNotifier);
  586. });
  587. it("should do nothing if the browser is not the `selectedBrowser`", () => {
  588. const someOtherFakeBrowser = {};
  589. CFRPageActions.updatePageActions(someOtherFakeBrowser);
  590. assert.notCalled(PageAction.prototype.showAddressBarNotifier);
  591. assert.notCalled(PageAction.prototype.hideAddressBarNotifier);
  592. });
  593. it("should hideAddressBarNotifier the pageAction if a recommendation doesn't exist for the given browser", () => {
  594. CFRPageActions.RecommendationMap.delete(fakeBrowser);
  595. CFRPageActions.updatePageActions(fakeBrowser);
  596. assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
  597. });
  598. it("should show the pageAction if a recommendation exists and the host matches", () => {
  599. CFRPageActions.updatePageActions(fakeBrowser);
  600. assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
  601. assert.calledWith(PageAction.prototype.showAddressBarNotifier, savedRec);
  602. });
  603. it("should hideAddressBarNotifier the pageAction and delete the recommendation if the recommendation exists but the host doesn't match", () => {
  604. const someOtherFakeHost = "subdomain.mozilla.com";
  605. fakeBrowser.documentURI.host = someOtherFakeHost;
  606. assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
  607. CFRPageActions.updatePageActions(fakeBrowser);
  608. assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
  609. assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
  610. });
  611. it("should not call `delete` if retain is true", () => {
  612. savedRec.retain = true;
  613. fakeBrowser.documentURI.host = "subdomain.mozilla.com";
  614. assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
  615. CFRPageActions.updatePageActions(fakeBrowser);
  616. assert.propertyVal(savedRec, "retain", false);
  617. assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
  618. assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
  619. });
  620. it("should call `delete` if retain is false", () => {
  621. savedRec.retain = false;
  622. fakeBrowser.documentURI.host = "subdomain.mozilla.com";
  623. assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
  624. CFRPageActions.updatePageActions(fakeBrowser);
  625. assert.propertyVal(savedRec, "retain", false);
  626. assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
  627. assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
  628. });
  629. });
  630. describe("forceRecommendation", () => {
  631. it("should succeed and add an element to the RecommendationMap", async () => {
  632. assert.isTrue(await CFRPageActions.forceRecommendation({browser: fakeBrowser}, fakeRecommendation, dispatchStub));
  633. assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
  634. id: fakeRecommendation.id,
  635. content: fakeRecommendation.content,
  636. });
  637. });
  638. it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => {
  639. const win = fakeBrowser.ownerGlobal;
  640. assert.isFalse(CFRPageActions.PageActionMap.has(win));
  641. await CFRPageActions.forceRecommendation({browser: fakeBrowser}, fakeRecommendation, dispatchStub);
  642. const pageAction = CFRPageActions.PageActionMap.get(win);
  643. assert.equal(win, pageAction.window);
  644. assert.equal(dispatchStub, pageAction._dispatchToASRouter);
  645. assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
  646. });
  647. });
  648. describe("addRecommendation", () => {
  649. it("should fail and not add a recommendation if the browser is part of a private window", async () => {
  650. global.PrivateBrowsingUtils.isWindowPrivate.returns(true);
  651. assert.isFalse(await CFRPageActions.addRecommendation(fakeBrowser, fakeHost, fakeRecommendation, dispatchStub));
  652. assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
  653. });
  654. it("should fail and not add a recommendation if the browser is not the selected browser", async () => {
  655. global.gBrowser.selectedBrowser = {}; // Some other browser
  656. assert.isFalse(await CFRPageActions.addRecommendation(fakeBrowser, fakeHost, fakeRecommendation, dispatchStub));
  657. });
  658. it("should fail and not add a recommendation if the host doesn't match", async () => {
  659. const someOtherFakeHost = "subdomain.mozilla.com";
  660. assert.isFalse(await CFRPageActions.addRecommendation(fakeBrowser, someOtherFakeHost, fakeRecommendation, dispatchStub));
  661. });
  662. it("should otherwise succeed and add an element to the RecommendationMap", async () => {
  663. assert.isTrue(await CFRPageActions.addRecommendation(fakeBrowser, fakeHost, fakeRecommendation, dispatchStub));
  664. assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
  665. id: fakeRecommendation.id,
  666. host: fakeHost,
  667. content: fakeRecommendation.content,
  668. });
  669. });
  670. it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => {
  671. const win = fakeBrowser.ownerGlobal;
  672. assert.isFalse(CFRPageActions.PageActionMap.has(win));
  673. await CFRPageActions.addRecommendation(fakeBrowser, fakeHost, fakeRecommendation, dispatchStub);
  674. const pageAction = CFRPageActions.PageActionMap.get(win);
  675. assert.equal(win, pageAction.window);
  676. assert.equal(dispatchStub, pageAction._dispatchToASRouter);
  677. assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
  678. });
  679. it("should add the right url if we fetched and addon install URL", async () => {
  680. fakeRecommendation.template = "cfr_doorhanger";
  681. await CFRPageActions.addRecommendation(fakeBrowser, fakeHost, fakeRecommendation, dispatchStub);
  682. const recommendation = CFRPageActions.RecommendationMap.get(fakeBrowser);
  683. // sanity check - just go through some of the rest of the attributes to make sure they were untouched
  684. assert.equal(recommendation.id, fakeRecommendation.id);
  685. assert.equal(recommendation.content.heading_text, fakeRecommendation.content.heading_text);
  686. assert.equal(recommendation.content.addon, fakeRecommendation.content.addon);
  687. assert.equal(recommendation.content.text, fakeRecommendation.content.text);
  688. assert.equal(recommendation.content.buttons.secondary, fakeRecommendation.content.buttons.secondary);
  689. assert.equal(recommendation.content.buttons.primary.action.id, fakeRecommendation.content.buttons.primary.action.id);
  690. delete fakeRecommendation.template;
  691. });
  692. it("should prevent a second message if one is currently displayed", async () => {
  693. const secondMessage = {...fakeRecommendation, id: "second_message"};
  694. let messageAdded = await CFRPageActions.addRecommendation(fakeBrowser, fakeHost, fakeRecommendation, dispatchStub);
  695. assert.isTrue(messageAdded);
  696. assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
  697. id: fakeRecommendation.id,
  698. host: fakeHost,
  699. content: fakeRecommendation.content,
  700. });
  701. messageAdded = await CFRPageActions.addRecommendation(fakeBrowser, fakeHost, secondMessage, dispatchStub);
  702. // Adding failed
  703. assert.isFalse(messageAdded);
  704. // First message is still there
  705. assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
  706. id: fakeRecommendation.id,
  707. host: fakeHost,
  708. content: fakeRecommendation.content,
  709. });
  710. });
  711. it("should send impressions just for the first message", async () => {
  712. const secondMessage = {...fakeRecommendation, id: "second_message"};
  713. await CFRPageActions.addRecommendation(fakeBrowser, fakeHost, fakeRecommendation, dispatchStub);
  714. await CFRPageActions.addRecommendation(fakeBrowser, fakeHost, secondMessage, dispatchStub);
  715. // Doorhanger telemetry + Impression for just 1 message
  716. assert.calledTwice(dispatchStub);
  717. const [firstArgs] = dispatchStub.firstCall.args;
  718. const [secondArgs] = dispatchStub.secondCall.args;
  719. assert.equal(firstArgs.data.id, secondArgs.data.message_id);
  720. });
  721. });
  722. describe("clearRecommendations", () => {
  723. const createFakePageAction = () => ({hideAddressBarNotifier: sandbox.stub()});
  724. const windows = [{}, {}, {closed: true}];
  725. const browsers = [{}, {}, {}, {}];
  726. beforeEach(() => {
  727. CFRPageActions.PageActionMap.set(windows[0], createFakePageAction());
  728. CFRPageActions.PageActionMap.set(windows[2], createFakePageAction());
  729. for (const browser of browsers) {
  730. CFRPageActions.RecommendationMap.set(browser, {});
  731. }
  732. globals.set({Services: {wm: {getEnumerator: () => windows}}});
  733. });
  734. it("should hideAddressBarNotifier the PageActions of any existing, non-closed windows", () => {
  735. const pageActions = windows.map(win => CFRPageActions.PageActionMap.get(win));
  736. CFRPageActions.clearRecommendations();
  737. // Only the first window had a PageAction and wasn't closed
  738. assert.calledOnce(pageActions[0].hideAddressBarNotifier);
  739. assert.isUndefined(pageActions[1]);
  740. assert.notCalled(pageActions[2].hideAddressBarNotifier);
  741. });
  742. it("should clear the PageActionMap and the RecommendationMap", () => {
  743. CFRPageActions.clearRecommendations();
  744. // Both are WeakMaps and so are not iterable, cannot be cleared, and
  745. // cannot have their length queried directly, so we have to check
  746. // whether previous elements still exist
  747. assert.lengthOf(windows, 3);
  748. for (const win of windows) {
  749. assert.isFalse(CFRPageActions.PageActionMap.has(win));
  750. }
  751. assert.lengthOf(browsers, 4);
  752. for (const browser of browsers) {
  753. assert.isFalse(CFRPageActions.RecommendationMap.has(browser));
  754. }
  755. });
  756. });
  757. });
  758. });